1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
//! #2002 — PTY acceptance for the `/settings` form: open it on a real
//! terminal, pick a field the way an operator does, apply a value, and prove
//! the receipt landed. Same self-re-exec tier as `panel_raw_mode_pty_test`
//! (which this is modelled on), and it grounds the fully-mocked
//! `settings_form` unit tests the way `prompt_visibility_test` grounds the
//! line arbiter's: no mock can observe what actually reached the screen.
//!
//! The assertions are the honest kind (#1986 bar): the field menu is checked
//! on the rendered GRID, not in the emitted byte stream, and the receipt is
//! read back from the file the child's `apply_and_record` wrote — so a pass
//! cannot be explained by bytes that were emitted and then painted over, nor
//! by an apply that never recorded.
use std::time::{Duration, Instant};
use tests_pty::{screen_grid, Pty};
use crate::prompt_visibility_test::wait_for_child;
const CHILD_TEST: &str = "settings_form_pty_test::settings_form_child";
const REACH_TIMEOUT: Duration = Duration::from_secs(60);
const EXIT_TIMEOUT: Duration = Duration::from_secs(60);
/// The child half: runs the EXACT ask wiring `dispatch_slash`'s `"settings"`
/// arm uses — `suspend_for_prompt` + `present_on_terminal` — so this proves
/// the production path, not a test-only twin of it.
#[test]
#[ignore = "child process of the settings-form PTY acceptance test"]
fn settings_form_child() {
if std::env::var_os("NEWT_SETTINGS_PTY_CHILD").is_none() {
return;
}
let ask = |interaction: &newt_core::interaction_surface::SurfaceInteraction| {
let window = newt_core::tty::Terminal::suspend_for_prompt(
newt_core::tty::TerminalTaker::RichSurfaceModal,
);
crate::permissions::present_on_terminal(&window, interaction)
};
for line in crate::settings_form::run(&ask, "") {
println!("{line}");
}
}
/// Every field label the bare-form menu must show, **taken from `Field::ALL`
/// rather than copied beside it** (#2009 PR4).
///
/// §5 requires each slice that adds a field to extend this grid. A
/// hand-maintained list is a way to forget: the grid would go on passing over
/// the six fields it knew about while a seventh rendered or did not. Reading
/// the form's own field list means adding a field extends the acceptance test
/// by construction, and a field that stops rendering fails here whether or not
/// anyone remembered this file.
///
/// Still asserted one by one below, so a failure names the missing row instead
/// of "menu absent".
fn field_labels() -> Vec<&'static str> {
crate::settings_form::Field::ALL
.iter()
.map(|f| f.label())
.collect()
}
#[test]
fn the_settings_form_renders_picks_applies_and_receipts() {
// Real fs, deliberately: this is the expensive tier, and the receipt on
// disk is half of what it exists to prove. Isolated per-run directory;
// the child inherits it as NEWT_CONFIG_DIR so nothing touches ~/.newt.
let home = std::env::temp_dir().join(format!(
"newt-settings-pty-{}-{}",
std::process::id(),
Instant::now().elapsed().as_nanos()
));
std::fs::create_dir_all(&home).expect("create the isolated config dir");
let pty = Pty::open();
let mut child = std::process::Command::new(
std::env::current_exe().expect("the test binary re-invokes itself"),
)
.args(["--exact", CHILD_TEST, "--ignored", "--nocapture"])
.env("NEWT_SETTINGS_PTY_CHILD", "form")
.env("NEWT_CONFIG_DIR", &home)
// **The child must not inherit a blanked receipt path.**
//
// `GlobalSettingsGuard` sets `NEWT_SETTINGS_RECEIPTS=""` process-globally
// so mocked tests write no journal — and an empty override makes
// `receipt_path()` return `None`. A child spawned while some other test in
// this binary holds that guard inherits the blank, writes no receipt, and
// fails here with a NotFound that names the wrong culprit.
//
// Removing the variable rather than setting it keeps what this test is
// FOR: the receipt landing beside `settings.toml` by the default
// derivation from `NEWT_CONFIG_DIR`. Pinning an explicit path would make
// the assertion pass without exercising that derivation at all.
.env_remove(newt_core::settings_receipt::RECEIPT_PATH_ENV)
.stdin(pty.slave_stdio())
.stdout(pty.slave_stdio())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn the pty child");
// Drive the two menu levels. `wait_on_grid` plays the terminal (answers
// DSR) while polling the RENDERED screen for a marker.
let mut transcript = String::new();
let mut answered_dsr = false;
// Wait until `marker` appears on the RENDERED grid. `row_only` additionally
// requires it on a numbered `[n] …` option row, so a mention in a hint or a
// status line cannot satisfy the wait.
let mut wait_on_grid_inner = |marker: &str, row_only: bool| -> String {
let deadline = Instant::now() + REACH_TIMEOUT;
loop {
let screen = pty.screen();
transcript.push_str(&screen);
if !answered_dsr && transcript.contains("\u{1b}[6n") {
pty.type_in("\u{1b}[3;1R");
answered_dsr = true;
}
let grid = screen_grid(&screen).join("\n");
let hit = if row_only {
grid.lines()
.any(|l| l.trim_start().starts_with('[') && l.contains(marker))
} else {
grid.contains(marker)
};
if hit {
return grid;
}
assert!(
Instant::now() < deadline,
"`{marker}` never RENDERED; last grid:\n{grid}"
);
std::thread::sleep(Duration::from_millis(20));
}
};
// 1. The bare form: every field label visible on the grid.
let mut wait_on_grid = |m: &str| wait_on_grid_inner(m, false);
let labels = field_labels();
assert!(
labels.len() >= 7,
"the form lost fields; this grid covers {} of them",
labels.len()
);
let menu = wait_on_grid(labels[labels.len() - 1]);
for label in &labels {
assert!(
menu.contains(label),
"field `{label}` not rendered:\n{menu}"
);
}
// 2. Pick field [1] (edit-mode) by number, exactly as an operator types.
//
// Wait for an OPTION row, not for the word: the vi mode hint advertises
// `/nano /emacs` (`rich_input.rs` `mode_hint`), so waiting on a bare
// "nano" can return while the FIELD menu is still up — the hint matched,
// not the value menu — and the pick below then reads a digit off the
// hint and submits a wrong choice, leaving no receipt. That is the CI
// failure this fixes (#2006's turn-conditional hint changed the timing
// that had been hiding it).
pty.type_in("1\r");
// #2017: anchor on text only the VALUE menu can render. "nano" alone is
// ambiguous — the FIELD menu row reads `[1] line-editor key bindings —
// currently nano` on a fresh config, and it is also a numbered row, so
// even a `[n] …` anchor matches it and the pick submits the wrong number.
// "nano-style editing" is the value option's own description
// (`settings_form.rs` `Field::EditMode`) and appears nowhere else.
let values = wait_on_grid_inner("nano-style editing", true);
// 3. Pick `nano` by ITS number, read off the rendered menu rather than
// assumed — the menu's order is the form's business, not this test's.
//
// The row must be an OPTION row, not merely a line containing "nano":
// the vi mode hint advertises `/nano /emacs` (rich_input.rs `mode_hint`),
// so a bare `contains("nano")` matches the hint, reads a digit out of a
// timestamp, and submits a wrong choice. This test passed on #2004 only
// because the hint happened to sit outside the captured grid; #2006's
// turn-conditional hint moved it in, which is the good kind of luck —
// a latent brittleness surfacing while someone is watching.
let nano_row = values
.lines()
.find(|l| l.trim_start().starts_with('[') && l.contains("nano-style editing"))
.expect("a numbered `[n] … nano-style editing` option row was on the grid");
let number: String = nano_row
.chars()
.skip_while(|c| !c.is_ascii_digit())
.take_while(char::is_ascii_digit)
.collect();
assert!(!number.is_empty(), "no option number on: {nano_row}");
pty.type_in(&format!("{number}\r"));
let status =
wait_for_child(&mut child, EXIT_TIMEOUT).expect("the form child exited within the timeout");
assert!(status.success(), "the form child failed: {status:?}");
// 4. The receipt is the point (#1965 closed this class): the change is on
// disk, content-addressed, with the verb bound in.
let receipts = std::fs::read_to_string(home.join("receipts.jsonl"))
.expect("apply_and_record wrote receipts.jsonl beside settings.toml");
assert!(
receipts.contains("nano"),
"the applied value is not in the receipt: {receipts}"
);
assert!(
receipts.contains("/settings"),
"the verb is not bound into the receipt: {receipts}"
);
let parsed = newt_core::settings_receipt::read_jsonl(&receipts);
assert!(
!parsed.is_empty(),
"the receipt line did not parse back: {receipts}"
);
let _ = std::fs::remove_dir_all(&home);
}