@@ -9,6 +9,12 @@ use std::os::fd::{FromRawFd, RawFd};
99use std::os::unix::fs::PermissionsExt;
1010use std::path::PathBuf;
1111use std::process::{Command, Stdio};
12+use unicode_width::UnicodeWidthStr;
13+
14+const CLEAR_LINE: &str = "\r\x1b[2K";
15+// A bounded builtin fold keeps evaluation active for multiple progress frames
16+// without constructing a deep recursive value or touching the network.
17+const SLOW_SUM: &str = "foldl (+) 0 (range 0 500000)";
1218
1319struct Fixture {
1420 root: tempfile::TempDir,
@@ -84,8 +90,14 @@ struct Out {
8490
8591/// Return the master to capture output and the slave to install as stdout or
8692/// stderr. Disable newline translation so assertions see the bytes j wrote.
87−fn pty() -> (File, File) {
93+fn pty(columns: u16) -> (File, File) {
8894 let (mut master, mut slave): (RawFd, RawFd) = (-1, -1);
95+ let size = libc::winsize {
96+ ws_row: 24,
97+ ws_col: columns,
98+ ws_xpixel: 0,
99+ ws_ypixel: 0,
100+ };
89101 assert_eq!(
90102 unsafe {
91103 libc::openpty(
@@ -93,7 +105,7 @@ fn pty() -> (File, File) {
93105 &mut slave,
94106 std::ptr::null_mut(),
95107 std::ptr::null(),
96− std::ptr::null(),
108+ &size,
97109 )
98110 },
99111 0,
@@ -128,12 +140,21 @@ fn read_stream(mut reader: Box<dyn Read + Send>) -> String {
128140 String::from_utf8(bytes).unwrap()
129141}
130142
131−fn capture(mut command: Command, stdout_tty: bool, stderr_tty: bool) -> Out {
132− let stdout = stdout_tty.then(pty).map(|(master, slave)| {
143+fn capture(command: Command, stdout_tty: bool, stderr_tty: bool) -> Out {
144+ capture_with_stderr_width(command, stdout_tty, stderr_tty, 80)
145+}
146+
147+fn capture_with_stderr_width(
148+ mut command: Command,
149+ stdout_tty: bool,
150+ stderr_tty: bool,
151+ columns: u16,
152+) -> Out {
153+ let stdout = stdout_tty.then(|| pty(80)).map(|(master, slave)| {
133154 command.stdout(slave);
134155 master
135156 });
136− let stderr = stderr_tty.then(pty).map(|(master, slave)| {
157+ let stderr = stderr_tty.then(|| pty(columns)).map(|(master, slave)| {
137158 command.stderr(slave);
138159 master
139160 });
@@ -201,6 +222,26 @@ fn without_sgr(text: &str) -> String {
201222 plain
202223}
203224
225+fn assert_progress(out: &Out, label: &str) {
226+ let frames: Vec<_> = out.stderr.split(CLEAR_LINE)
227+ .filter(|frame| frame.contains(label))
228+ .map(without_sgr)
229+ .collect();
230+ assert!(frames.len() >= 2, "no repeated {label} frames: {:?}", out.stderr);
231+ let mut bars = std::collections::BTreeSet::new();
232+ for frame in &frames {
233+ let (_, details) = frame.split_once(" [")
234+ .unwrap_or_else(|| panic!("missing activity bar: {frame:?}"));
235+ let (bar, elapsed) = details.split_once("] ")
236+ .unwrap_or_else(|| panic!("missing elapsed time: {frame:?}"));
237+ assert!(bar.contains('━') && bar.contains('·'), "invalid activity bar: {bar:?}");
238+ assert!(elapsed.strip_suffix('s').and_then(|s| s.parse::<f64>().ok()).is_some(),
239+ "invalid elapsed time: {elapsed:?}");
240+ bars.insert(bar);
241+ }
242+ assert!(bars.len() >= 2, "activity bar did not animate: {frames:?}");
243+}
244+
204245#[test]
205246fn terminal_tree_is_colored_and_raw_text_is_unchanged() {
206247 let fixture = Fixture::new();
@@ -218,6 +259,65 @@ fn terminal_tree_is_colored_and_raw_text_is_unchanged() {
218259 assert_eq!(terminal.stdout, "a rainbow\nkeeps its bytes\n");
219260}
220261
262+#[test]
263+fn slow_expression_animates_and_clears_before_reporting_failure() {
264+ let fixture = Fixture::new();
265+ fixture.init();
266+ let expr = format!(
267+ "if {SLOW_SUM} == 124999750000 then crash \"expression fixture failure\" else \"\""
268+ );
269+ let out = capture(fixture.command(&[&expr]), true, true);
270+ assert_eq!(out.code, 1, "{}", out.stderr);
271+ assert!(out.stdout.is_empty(), "progress leaked into stdout: {:?}", out.stdout);
272+ assert_progress(&out, "Evaluating expression");
273+ let last_clear = out.stderr.rfind(CLEAR_LINE).expect("progress was never cleared");
274+ let diagnostic = without_sgr(&out.stderr[last_clear + CLEAR_LINE.len()..]);
275+ assert!(diagnostic.starts_with("j:"), "progress label was not cleared: {diagnostic:?}");
276+ assert!(diagnostic.contains("expression fixture failure"), "{diagnostic:?}");
277+ assert!(diagnostic.ends_with('\n'), "diagnostic did not end a line: {diagnostic:?}");
278+}
279+
280+#[test]
281+fn slow_implicit_application_preserves_output_and_respects_opt_outs() {
282+ let fixture = Fixture::new();
283+ fixture.init();
284+ // Evaluating this lambda is fast; applying it to the implicit repository
285+ // must remain inside the same progress phase.
286+ let expr = format!(
287+ "\\repo -> if {SLOW_SUM} == 124999750000 then \"computed\\nunchanged\\n\" else crash \"unexpected sum\""
288+ );
289+ let animated = capture(fixture.command(&[&expr]), true, true);
290+ assert_eq!(animated.code, 0, "{}", animated.stderr);
291+ assert_eq!(animated.stdout, "computed\nunchanged\n");
292+ assert_progress(&animated, "Evaluating expression");
293+ assert!(animated.stderr.ends_with(CLEAR_LINE), "progress was not cleared: {:?}", animated.stderr);
294+
295+ let mut command = fixture.command(&[&expr]);
296+ command.env("J_ANIMATIONS", "off");
297+ let opted_out = capture(command, true, true);
298+ let piped = capture(fixture.command(&[&expr]), false, false);
299+ for out in [opted_out, piped] {
300+ assert_eq!(out.code, 0, "{}", out.stderr);
301+ assert_eq!(out.stdout, animated.stdout);
302+ assert_no_motion(&out);
303+ }
304+}
305+
306+#[test]
307+fn slow_configuration_animates_and_clears_before_output() {
308+ let fixture = Fixture::new();
309+ fixture.init();
310+ let path = fixture.cfg.join("j/config.j");
311+ let mut config = std::fs::read_to_string(&path).unwrap();
312+ config.push_str(&format!("\nterminalWork = {SLOW_SUM}\n"));
313+ std::fs::write(path, config).unwrap();
314+ let out = capture(fixture.command(&["show terminalWork"]), true, true);
315+ assert_eq!(out.code, 0, "{}", out.stderr);
316+ assert_eq!(out.stdout, "124999750000\n");
317+ assert_progress(&out, "Evaluating configuration");
318+ assert!(out.stderr.ends_with(CLEAR_LINE), "progress was not cleared: {:?}", out.stderr);
319+}
320+
221321#[test]
222322fn slow_work_animates_and_clears_before_reporting_failure() {
223323 let fixture = Fixture::new();
@@ -253,6 +353,24 @@ fn successful_slow_work_clears_before_its_confirmation() {
253353 assert!(fixture.dir.join("clone/.jj").is_dir());
254354}
255355
356+#[test]
357+fn progress_fits_a_narrow_stderr_terminal() {
358+ let fixture = Fixture::new();
359+ // stdout stays 80 columns wide: progress must measure its own stderr.
360+ let out = capture_with_stderr_width(fixture.slow_clone(false), true, true, 18);
361+ assert_eq!(out.code, 0, "{}", out.stderr);
362+ let segments: Vec<_> = out.stderr.split(CLEAR_LINE).skip(1).collect();
363+ let (confirmation, frames) = segments.split_last().expect("progress was never cleared");
364+ assert!(frames.len() >= 2, "no repeated frames: {:?}", out.stderr);
365+ for frame in frames {
366+ let plain = without_sgr(frame);
367+ assert!(!plain.contains('\n'), "progress used multiple rows: {plain:?}");
368+ assert!(plain.width() <= 17, "progress wrapped the 18-column terminal: {plain:?}");
369+ }
370+ assert!(without_sgr(confirmation).starts_with('✓'), "confirmation was not preserved: {confirmation:?}");
371+ assert!(out.stdout.is_empty(), "progress leaked into stdout: {:?}", out.stdout);
372+}
373+
256374#[test]
257375fn inherited_difft_diagnostic_is_not_erased_by_progress() {
258376 let fixture = Fixture::new();