test: 1a --- A8 raw PTY delivery, and the negotiated-v23 refusal
The last two of the three discriminators. Each is proven by a mutation
that reinstates the defect it exists to catch.
**A8, delivered rather than routed.** The producer row proved a
terminal-focused session reaches the `TextInput` branch; this one
observes the CHILD PROCESS. A real PTY runs `printf '\033[?2004h'; exec
cat > FILE`, so the terminal turns bracketed paste ON and then records
exactly what arrives on its stdin.
**The enabled mode is the entire precondition**, and the row waits for
the child's own mode-set to be parsed before typing rather than assuming
it: with bracketed paste OFF, "no markers" is true of every code path
including a paste, so the assertion would pass against the behaviour it
forbids. The contrast closes it from the other side — through the SAME
terminal in the SAME mode, a paste IS bracketed. One path marked and the
other not, both observed at the PTY.
`M-1a-4` routes typed text through `encode_paste` and the row fails with
the forbidden bytes in hand:
`"\u{1b}[200~héllo\u{301}\u{1b}[201~"`.
**The negotiated-v23 refusal** gets its own suite, because it needs a
live daemon. A refusal is the hardest claim to witness honestly —
"nothing happened" is also what a dead daemon, a desynchronized stream
or a broken test look like — so the row pairs it with a POSITIVE CONTROL
on the same session: after the refused `TextInput`, an ordinary `Key`
that must take effect. Events from one session are processed in order,
so the control's edit arriving with no preceding `REFUSED` edit means
the gate fired rather than that the daemon was asleep.
Its complement runs the same traffic on a v24 session and requires the
edit to land, so the pair cannot be satisfied by `TextInput` being
broken outright.
`M-1a-5` disables the inbound gate and the v23 row fails with `REFUSED`
visible inside the CRDT op — a v23 peer editing a buffer through a
variant its session never declared, which is precisely the hole review
round 1 identified.
One setup lesson, recorded because it cost a red: the A8 row first
failed with an empty file, and the cause was that the frontend's view
was never pointed at the terminal buffer, so `active_terminal_key`
returned `None` and the DOCUMENT path ran. It now asserts that
precondition through public state before typing — a row that quietly
tests the document path and reports a terminal result is worse than one
that fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
parent
3ed37f7786
commit
e3a19a4e63
|
|
@ -232,5 +232,179 @@ fn a9_a_payload_at_the_cap_is_inserted_whole() {
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// A8 — a terminal receives RAW UTF-8, never bracketed paste
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// A8, delivered rather than merely routed: the child process receives
|
||||
/// the exact UTF-8 bytes, **while bracketed-paste mode is ENABLED**, and
|
||||
/// no `ESC[200~` / `ESC[201~` markers.
|
||||
///
|
||||
/// **The enabled mode is the whole precondition.** With bracketed paste
|
||||
/// off, "no markers" is true of every code path including a paste, so
|
||||
/// the assertion would pass against the behaviour it exists to forbid.
|
||||
/// The row therefore waits for the child's own `ESC[?2004h` to be
|
||||
/// parsed, asserts the mode really is on, and only then types.
|
||||
///
|
||||
/// The contrast at the end is what makes it a discriminator: through the
|
||||
/// same terminal in the same mode, a PASTE does get the markers. One
|
||||
/// path bracketed and the other not, observed at the PTY.
|
||||
#[test]
|
||||
fn a8_a_terminal_receives_raw_utf8_with_bracketed_paste_enabled() {
|
||||
use pmacs::terminal::TerminalSpec;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let sink = dir.path().join("received");
|
||||
let sink_disp = sink.display().to_string();
|
||||
|
||||
let mut s = EditorState::new_with_roots(&crate::iso::roots());
|
||||
|
||||
// The child turns bracketed paste ON, then copies its stdin to a
|
||||
// file so the test can read exactly what arrived on the PTY.
|
||||
let script = format!("printf '\\033[?2004h'; exec cat > {sink_disp}");
|
||||
let mut spec = TerminalSpec::new("/bin/sh");
|
||||
spec.args = vec!["-c".into(), script];
|
||||
spec.rows = 24;
|
||||
spec.cols = 80;
|
||||
let buffer_id = s
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.open(
|
||||
spec,
|
||||
&mut s.core.borrow_mut(),
|
||||
&mut s.process_supervisor.borrow_mut(),
|
||||
)
|
||||
.expect("open terminal");
|
||||
|
||||
// Point this frontend's view at the terminal buffer, the way a
|
||||
// daemon-side buffer switch does. Without it `active_terminal_key`
|
||||
// returns `None`, the terminal branch is never taken, and the row
|
||||
// fails for a setup reason rather than a behavioural one — which is
|
||||
// exactly how it first failed.
|
||||
let window_id = attach_terminal_view(&s, FID, buffer_id);
|
||||
let key = pmacs::terminal::TerminalViewKey::new(FID, window_id, buffer_id);
|
||||
// Precondition, asserted rather than assumed: this frontend's
|
||||
// ACTIVE window shows the terminal buffer. A row that silently
|
||||
// failed this would be testing the document path and reporting it
|
||||
// as a terminal result.
|
||||
{
|
||||
let core = s.core.borrow();
|
||||
let view = core.views.get(&FID).expect("view registered");
|
||||
let active = core.windows.get(&view.active).expect("active window");
|
||||
assert_eq!(active.buffer_id, buffer_id);
|
||||
assert!(
|
||||
s.terminal_manager.borrow().is_terminal(buffer_id),
|
||||
"and the manager agrees it is a terminal"
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for the child's mode-set to be parsed — a condition, not a
|
||||
// sleep, so a slow machine waits longer rather than failing.
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
s.tick_processes();
|
||||
let on = s
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.modes_for_view(key)
|
||||
.is_some_and(|m| m.bracketed_paste);
|
||||
if on {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"child never enabled bracketed paste; the precondition this \
|
||||
row depends on was never established"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
// Multi-byte and multi-scalar, so a byte-level mistake shows up.
|
||||
let typed = "h\u{e9}llo\u{301}";
|
||||
s.dispatch_text_input(FID, typed);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let got = loop {
|
||||
s.tick_processes();
|
||||
let got = std::fs::read(&sink).unwrap_or_default();
|
||||
if got.len() >= typed.len() {
|
||||
break got;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"the child never received the typed text; got {got:?}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&got),
|
||||
typed,
|
||||
"the PTY must receive the exact UTF-8 that was typed"
|
||||
);
|
||||
let text = String::from_utf8_lossy(&got).into_owned();
|
||||
assert!(
|
||||
!text.contains("\u{1b}[200~") && !text.contains("\u{1b}[201~"),
|
||||
"typed text must NOT be bracketed: {text:?}"
|
||||
);
|
||||
|
||||
// The contrast, through the same terminal in the same mode: a paste
|
||||
// IS bracketed. Without this the row above could pass because the
|
||||
// mode was somehow inert rather than because the code is right.
|
||||
assert!(
|
||||
s.dispatch_paste(FID, b"pasted"),
|
||||
"the terminal claims the paste"
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
s.tick_processes();
|
||||
let all = String::from_utf8_lossy(&std::fs::read(&sink).unwrap_or_default()).into_owned();
|
||||
if all.contains("\u{1b}[200~") {
|
||||
assert!(
|
||||
all.contains("pasted") && all.contains("\u{1b}[201~"),
|
||||
"a paste is bracketed on both sides: {all:?}"
|
||||
);
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"a paste through the same terminal must be bracketed; got {all:?}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a frontend view whose active window shows `buffer_id`.
|
||||
fn attach_terminal_view(
|
||||
state: &EditorState,
|
||||
frontend_id: FrontendId,
|
||||
buffer_id: pmacs::buffer::BufferId,
|
||||
) -> pmacs::window::WindowId {
|
||||
use pmacs::window::{FrontendView, Layout, Window, WindowId};
|
||||
let mut core = state.core.borrow_mut();
|
||||
let text_view = {
|
||||
let registry = core.registry.clone();
|
||||
let registry = registry.borrow();
|
||||
let buffer = registry.get(buffer_id).expect("buffer present");
|
||||
pmacs::text_view::TextView::new(buffer)
|
||||
};
|
||||
let window_id = WindowId::next();
|
||||
core.windows
|
||||
.insert(window_id, Window::new(window_id, buffer_id, text_view));
|
||||
core.register_frontend_view(
|
||||
frontend_id,
|
||||
FrontendView {
|
||||
layout: Layout::single(window_id),
|
||||
active: window_id,
|
||||
fold_projection: true,
|
||||
panel_capable: true,
|
||||
frame_geometry: None,
|
||||
panel_hidden: false,
|
||||
},
|
||||
);
|
||||
window_id
|
||||
}
|
||||
|
||||
#[path = "common/iso.rs"]
|
||||
mod iso;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,186 @@
|
|||
//! GUI arc Stage 1a — the `TextInput` wire gate, against a real daemon.
|
||||
//!
|
||||
//! Separate from `gui_stage1a_acceptance` because these rows need a
|
||||
//! live daemon and a negotiated session; that suite is in-process.
|
||||
//!
|
||||
//! **The claim under test is a REFUSAL**, which is the hardest kind to
|
||||
//! witness honestly: "nothing happened" is also what a broken test,
|
||||
//! a dead daemon or a dropped connection look like. Every row here
|
||||
//! therefore pairs the refusal with a positive control on the same
|
||||
//! session — something that *does* take effect — so silence can only
|
||||
//! mean the gate fired.
|
||||
|
||||
#![cfg(feature = "crdt")]
|
||||
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pmacs_protocol::{
|
||||
ADVERTISED_PROTOCOL_VERSION, AttachRequest, CellSize, FrontendCapabilities, FrontendEvent,
|
||||
Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, SessionBootstrapRequest,
|
||||
TEXT_INPUT_MIN_VERSION, read_message, write_message,
|
||||
};
|
||||
|
||||
#[path = "common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
fn semantic_caps() -> FrontendCapabilities {
|
||||
FrontendCapabilities {
|
||||
synchronized_output: false,
|
||||
unicode_smp: true,
|
||||
true_color: true,
|
||||
mouse: false,
|
||||
bracketed_paste: false,
|
||||
terminal_kind: Some("stage1a".into()),
|
||||
multi_frontend: true,
|
||||
crdt_replica: true,
|
||||
semantic_render: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a semantic session that counter-offers `offer`.
|
||||
fn attach_semantic(
|
||||
daemon: &common::daemon::TestDaemon,
|
||||
offer: u32,
|
||||
) -> (UnixStream, pmacs_protocol::FrontendId) {
|
||||
let mut stream = daemon.connect();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||
.expect("set read timeout");
|
||||
let hello: Hello = read_message(&mut stream).expect("read daemon Hello");
|
||||
assert_eq!(
|
||||
hello.protocol_version, ADVERTISED_PROTOCOL_VERSION,
|
||||
"the server-first Hello stays at the compatibility baseline"
|
||||
);
|
||||
let fid = hello.assigned_frontend_id;
|
||||
write_message(
|
||||
&mut stream,
|
||||
&AttachRequest {
|
||||
protocol_version: offer,
|
||||
frontend_capabilities: semantic_caps(),
|
||||
initial_size: CellSize::new(24, 80),
|
||||
},
|
||||
)
|
||||
.expect("write AttachRequest");
|
||||
write_message(&mut stream, &SessionBootstrapRequest::default()).expect("write bootstrap");
|
||||
(stream, fid)
|
||||
}
|
||||
|
||||
fn pump<T>(
|
||||
stream: &mut UnixStream,
|
||||
what: &str,
|
||||
mut want: impl FnMut(&InstanceMessage) -> Option<T>,
|
||||
) -> T {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
while Instant::now() < deadline {
|
||||
match read_message::<InstanceMessage>(stream) {
|
||||
Ok(msg) => {
|
||||
if let Some(found) = want(&msg) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
Err(error) => panic!("{what}: read stopped: {error}"),
|
||||
}
|
||||
}
|
||||
panic!("timed out waiting for {what}");
|
||||
}
|
||||
|
||||
fn send_text_input(stream: &mut UnixStream, fid: pmacs_protocol::FrontendId, text: &str) {
|
||||
write_message(
|
||||
stream,
|
||||
&FrontendEvent::TextInput {
|
||||
frontend_id: fid,
|
||||
text: text.to_owned(),
|
||||
},
|
||||
)
|
||||
.expect("write TextInput");
|
||||
}
|
||||
|
||||
fn send_key(stream: &mut UnixStream, fid: pmacs_protocol::FrontendId, key: Key) {
|
||||
write_message(
|
||||
stream,
|
||||
&FrontendEvent::Key(KeyEvent {
|
||||
frontend_id: fid,
|
||||
key,
|
||||
mods: Modifiers::NONE,
|
||||
timestamp_ns: 0,
|
||||
}),
|
||||
)
|
||||
.expect("write key");
|
||||
}
|
||||
|
||||
/// **The discriminating witness for the inbound gate.** A session that
|
||||
/// negotiated v23 can still ENCODE `TextInput` — it is built from this
|
||||
/// same crate — so the daemon must refuse it on the authenticated
|
||||
/// session's negotiated version rather than trusting the producer to
|
||||
/// withhold.
|
||||
///
|
||||
/// The positive control is what makes the refusal legible: the SAME
|
||||
/// session then sends an ordinary `Key`, and that must take effect. So
|
||||
/// the session is alive, the stream is synchronized and the daemon is
|
||||
/// listening — silence about the `TextInput` is the gate, not the
|
||||
/// plumbing.
|
||||
#[test]
|
||||
fn a_v23_session_cannot_drive_an_edit_through_text_input() {
|
||||
assert_eq!(
|
||||
TEXT_INPUT_MIN_VERSION, 24,
|
||||
"this row is written against the v24 floor"
|
||||
);
|
||||
let daemon = common::daemon::TestDaemon::spawn();
|
||||
let (mut stream, fid) = attach_semantic(&daemon, TEXT_INPUT_MIN_VERSION - 1);
|
||||
|
||||
let buffer_id = pump(&mut stream, "first BufferSnapshot", |msg| match msg {
|
||||
InstanceMessage::BufferSnapshot { buffer_id, .. } => Some(*buffer_id),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
// Refused: encoded by a peer that never declared v24.
|
||||
send_text_input(&mut stream, fid, "REFUSED");
|
||||
|
||||
// The positive control, on the same session and after it.
|
||||
send_key(&mut stream, fid, Key::Char('k'));
|
||||
|
||||
// The first edit that reaches this session must be the CONTROL's,
|
||||
// never the refused text. Ordering carries the proof: the daemon
|
||||
// processes a session's events in order, so the control's edit
|
||||
// arriving with no preceding `REFUSED` edit means the TextInput was
|
||||
// dropped rather than merely slow.
|
||||
let op = pump(&mut stream, "the control's edit", |msg| match msg {
|
||||
InstanceMessage::CrdtOp {
|
||||
buffer_id: b, op, ..
|
||||
} if *b == buffer_id => Some(op.bytes.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let text = String::from_utf8_lossy(&op).into_owned();
|
||||
assert!(
|
||||
!text.contains("REFUSED"),
|
||||
"a v23 session must not be able to insert through TextInput; got {text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The complement, so the row above cannot pass because `TextInput` is
|
||||
/// broken outright: the SAME traffic on a v24 session **does** edit.
|
||||
#[test]
|
||||
fn a_v24_session_can_drive_an_edit_through_text_input() {
|
||||
let daemon = common::daemon::TestDaemon::spawn();
|
||||
let (mut stream, fid) = attach_semantic(&daemon, PROTOCOL_VERSION);
|
||||
|
||||
let buffer_id = pump(&mut stream, "first BufferSnapshot", |msg| match msg {
|
||||
InstanceMessage::BufferSnapshot { buffer_id, .. } => Some(*buffer_id),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
send_text_input(&mut stream, fid, "ACCEPTED");
|
||||
|
||||
let op = pump(&mut stream, "the TextInput edit", |msg| match msg {
|
||||
InstanceMessage::CrdtOp {
|
||||
buffer_id: b, op, ..
|
||||
} if *b == buffer_id => Some(op.bytes.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let text = String::from_utf8_lossy(&op).into_owned();
|
||||
assert!(
|
||||
text.contains("ACCEPTED"),
|
||||
"a v24 session must be able to insert through TextInput; got {text:?}"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue