Skip to main content

wasmsh_browser/
lib.rs

1//! Browser Web Worker integration for wasmsh.
2//!
3//! Thin adapter around [`wasmsh_runtime::WorkerRuntime`] that adds
4//! `wasm-bindgen` entry points for the browser worker.
5
6// Re-export the runtime so downstream consumers (testkit, benches) work unchanged.
7pub use wasmsh_runtime::{extglob_match, BrowserConfig, WorkerRuntime};
8
9// Protocol types used in tests (via `use super::*`) and wasm_bindings.
10#[cfg(test)]
11use wasmsh_protocol::{DiagnosticLevel, HostCommand, WorkerEvent, PROTOCOL_VERSION};
12
13#[cfg(test)]
14mod tests {
15    use super::*;
16
17    fn run_shell(input: &str) -> (Vec<WorkerEvent>, i32) {
18        let mut rt = WorkerRuntime::new();
19        rt.handle_command(HostCommand::Init {
20            step_budget: 0,
21            allowed_hosts: vec![],
22        });
23        let events = rt.handle_command(HostCommand::Run {
24            input: input.into(),
25        });
26        let status = events
27            .iter()
28            .find_map(|e| {
29                if let WorkerEvent::Exit(s) = e {
30                    Some(*s)
31                } else {
32                    None
33                }
34            })
35            .unwrap_or(-1);
36        (events, status)
37    }
38
39    fn get_stdout(events: &[WorkerEvent]) -> String {
40        let mut out = Vec::new();
41        for e in events {
42            if let WorkerEvent::Stdout(data) = e {
43                out.extend_from_slice(data);
44            }
45        }
46        String::from_utf8(out).unwrap_or_default()
47    }
48
49    fn get_stderr(events: &[WorkerEvent]) -> String {
50        let mut out = Vec::new();
51        for e in events {
52            if let WorkerEvent::Stderr(data) = e {
53                out.extend_from_slice(data);
54            }
55        }
56        String::from_utf8(out).unwrap_or_default()
57    }
58
59    #[test]
60    fn init_returns_version() {
61        let mut rt = WorkerRuntime::new();
62        let events = rt.handle_command(HostCommand::Init {
63            step_budget: 0,
64            allowed_hosts: vec![],
65        });
66        assert!(matches!(&events[0], WorkerEvent::Version(v) if v == PROTOCOL_VERSION));
67    }
68
69    #[test]
70    fn run_before_init_errors() {
71        let mut rt = WorkerRuntime::new();
72        let events = rt.handle_command(HostCommand::Run {
73            input: "echo hi".into(),
74        });
75        assert!(matches!(
76            &events[0],
77            WorkerEvent::Diagnostic(DiagnosticLevel::Error, _)
78        ));
79    }
80
81    #[test]
82    fn echo_hello() {
83        let (events, status) = run_shell("echo hello");
84        assert_eq!(status, 0);
85        assert_eq!(get_stdout(&events), "hello\n");
86    }
87
88    #[test]
89    fn true_false() {
90        let (_, status) = run_shell("true");
91        assert_eq!(status, 0);
92        let (_, status) = run_shell("false");
93        assert_eq!(status, 1);
94    }
95
96    #[test]
97    fn variable_assignment_and_echo() {
98        let (events, status) = run_shell("X=hello; echo $X");
99        assert_eq!(status, 0);
100        // Note: variable expansion happens through the word parser + expand
101        // The parser produces WordPart::Parameter("X"), expand resolves it
102        assert_eq!(get_stdout(&events), "hello\n");
103    }
104
105    #[test]
106    fn and_or_chain() {
107        let (events, _) = run_shell("true && echo yes");
108        assert_eq!(get_stdout(&events), "yes\n");
109
110        let (events, _) = run_shell("false && echo no");
111        assert_eq!(get_stdout(&events), "");
112
113        let (events, _) = run_shell("false || echo fallback");
114        assert_eq!(get_stdout(&events), "fallback\n");
115    }
116
117    #[test]
118    fn if_then_fi() {
119        let (events, status) = run_shell("if true; then echo yes; fi");
120        assert_eq!(status, 0);
121        assert_eq!(get_stdout(&events), "yes\n");
122    }
123
124    #[test]
125    fn if_else() {
126        let (events, _) = run_shell("if false; then echo no; else echo yes; fi");
127        assert_eq!(get_stdout(&events), "yes\n");
128    }
129
130    #[test]
131    fn for_loop() {
132        let (events, _) = run_shell("for x in a b c; do echo $x; done");
133        assert_eq!(get_stdout(&events), "a\nb\nc\n");
134    }
135
136    #[test]
137    fn parse_error_reported() {
138        let (events, status) = run_shell("|");
139        assert_eq!(status, 2);
140        assert!(events.iter().any(|e| matches!(e, WorkerEvent::Stderr(_))));
141    }
142
143    #[test]
144    fn negated_pipeline() {
145        let (_, status) = run_shell("! true");
146        assert_eq!(status, 1);
147        let (_, status) = run_shell("! false");
148        assert_eq!(status, 0);
149    }
150
151    #[test]
152    fn cancel_command() {
153        let mut rt = WorkerRuntime::new();
154        rt.handle_command(HostCommand::Init {
155            step_budget: 0,
156            allowed_hosts: vec![],
157        });
158        let events = rt.handle_command(HostCommand::Cancel);
159        assert!(matches!(
160            &events[0],
161            WorkerEvent::Diagnostic(DiagnosticLevel::Info, _)
162        ));
163    }
164
165    // ---- Utility dispatch ----
166
167    #[test]
168    fn touch_and_cat_via_shell() {
169        let mut rt = WorkerRuntime::new();
170        rt.handle_command(HostCommand::Init {
171            step_budget: 0,
172            allowed_hosts: vec![],
173        });
174        // touch creates a file, then we write via protocol and cat it
175        rt.handle_command(HostCommand::Run {
176            input: "touch /hello.txt".into(),
177        });
178        rt.handle_command(HostCommand::WriteFile {
179            path: "/hello.txt".into(),
180            data: b"hello world".to_vec(),
181        });
182        let events = rt.handle_command(HostCommand::Run {
183            input: "cat /hello.txt".into(),
184        });
185        assert_eq!(get_stdout(&events), "hello world");
186    }
187
188    #[test]
189    fn mkdir_and_ls_via_shell() {
190        let mut rt = WorkerRuntime::new();
191        rt.handle_command(HostCommand::Init {
192            step_budget: 0,
193            allowed_hosts: vec![],
194        });
195        rt.handle_command(HostCommand::Run {
196            input: "mkdir /mydir".into(),
197        });
198        rt.handle_command(HostCommand::Run {
199            input: "touch /mydir/a.txt".into(),
200        });
201        let events = rt.handle_command(HostCommand::Run {
202            input: "ls /mydir".into(),
203        });
204        assert_eq!(get_stdout(&events), "a.txt\n");
205    }
206
207    #[test]
208    fn unknown_command_reports_error() {
209        let (events, status) = run_shell("nonexistent_cmd");
210        assert_eq!(status, 127);
211        // Check stderr contains "command not found"
212        let stderr: String = events
213            .iter()
214            .filter_map(|e| {
215                if let WorkerEvent::Stderr(data) = e {
216                    Some(String::from_utf8_lossy(data).to_string())
217                } else {
218                    None
219                }
220            })
221            .collect();
222        assert!(stderr.contains("command not found"));
223    }
224
225    // ---- Protocol file operations ----
226
227    #[test]
228    fn protocol_write_and_read_file() {
229        let mut rt = WorkerRuntime::new();
230        rt.handle_command(HostCommand::Init {
231            step_budget: 0,
232            allowed_hosts: vec![],
233        });
234        let write_events = rt.handle_command(HostCommand::WriteFile {
235            path: "/test.txt".into(),
236            data: b"content".to_vec(),
237        });
238        assert!(write_events
239            .iter()
240            .any(|e| matches!(e, WorkerEvent::FsChanged(_))));
241
242        let read_events = rt.handle_command(HostCommand::ReadFile {
243            path: "/test.txt".into(),
244        });
245        assert_eq!(read_events, vec![WorkerEvent::Stdout(b"content".to_vec())]);
246    }
247
248    #[test]
249    fn protocol_list_dir() {
250        let mut rt = WorkerRuntime::new();
251        rt.handle_command(HostCommand::Init {
252            step_budget: 0,
253            allowed_hosts: vec![],
254        });
255        rt.handle_command(HostCommand::WriteFile {
256            path: "/a.txt".into(),
257            data: vec![],
258        });
259        rt.handle_command(HostCommand::WriteFile {
260            path: "/b.txt".into(),
261            data: vec![],
262        });
263        let events = rt.handle_command(HostCommand::ListDir { path: "/".into() });
264        let stdout = get_stdout(&events);
265        assert!(stdout.contains("a.txt"));
266        assert!(stdout.contains("b.txt"));
267    }
268
269    // ---- Redirections ----
270
271    #[test]
272    fn output_redirection_to_file() {
273        let mut rt = WorkerRuntime::new();
274        rt.handle_command(HostCommand::Init {
275            step_budget: 0,
276            allowed_hosts: vec![],
277        });
278        // echo hello > /out.txt should write to file, not stdout
279        let events = rt.handle_command(HostCommand::Run {
280            input: "echo hello > /out.txt".into(),
281        });
282        // stdout should be empty (redirected to file)
283        assert_eq!(get_stdout(&events), "");
284        // File should contain the output
285        let read_events = rt.handle_command(HostCommand::ReadFile {
286            path: "/out.txt".into(),
287        });
288        assert_eq!(get_stdout(&read_events), "hello\n");
289    }
290
291    #[test]
292    fn append_redirection() {
293        let mut rt = WorkerRuntime::new();
294        rt.handle_command(HostCommand::Init {
295            step_budget: 0,
296            allowed_hosts: vec![],
297        });
298        rt.handle_command(HostCommand::Run {
299            input: "echo line1 > /log.txt".into(),
300        });
301        rt.handle_command(HostCommand::Run {
302            input: "echo line2 >> /log.txt".into(),
303        });
304        let read_events = rt.handle_command(HostCommand::ReadFile {
305            path: "/log.txt".into(),
306        });
307        assert_eq!(get_stdout(&read_events), "line1\nline2\n");
308    }
309
310    #[test]
311    fn redirect_only_creates_file() {
312        let mut rt = WorkerRuntime::new();
313        rt.handle_command(HostCommand::Init {
314            step_budget: 0,
315            allowed_hosts: vec![],
316        });
317        rt.handle_command(HostCommand::Run {
318            input: "> /empty.txt".into(),
319        });
320        let read_events = rt.handle_command(HostCommand::ReadFile {
321            path: "/empty.txt".into(),
322        });
323        assert_eq!(get_stdout(&read_events), "");
324    }
325
326    // ---- Diagnostics surfaced as events ----
327
328    #[test]
329    fn vm_diagnostics_surfaced() {
330        let mut rt = WorkerRuntime::new();
331        rt.handle_command(HostCommand::Init {
332            step_budget: 0,
333            allowed_hosts: vec![],
334        });
335        // Running an unknown command triggers a diagnostic in the VM
336        let events = rt.handle_command(HostCommand::Run {
337            input: "unknown_cmd_xyz".into(),
338        });
339        // The "command not found" goes to stderr, not diagnostics,
340        // but the VM emits a diagnostic when CallBuiltin fails for unknown builtins.
341        // Since we dispatch unknown commands before IR, it goes to stderr.
342        // Let's test that stderr events are present.
343        assert!(events.iter().any(|e| matches!(e, WorkerEvent::Stderr(_))));
344    }
345
346    // ---- Integration: unset + default expansion ----
347
348    #[test]
349    fn unset_then_default_expansion() {
350        let mut rt = WorkerRuntime::new();
351        rt.handle_command(HostCommand::Init {
352            step_budget: 0,
353            allowed_hosts: vec![],
354        });
355        rt.handle_command(HostCommand::Run {
356            input: "X=hello".into(),
357        });
358        rt.handle_command(HostCommand::Run {
359            input: "unset X".into(),
360        });
361        // After unset, ${X:-default} should use the default
362        let events = rt.handle_command(HostCommand::Run {
363            input: "echo ${X:-default}".into(),
364        });
365        assert_eq!(get_stdout(&events), "default\n");
366    }
367
368    #[test]
369    fn readonly_prevents_reassignment() {
370        let mut rt = WorkerRuntime::new();
371        rt.handle_command(HostCommand::Init {
372            step_budget: 0,
373            allowed_hosts: vec![],
374        });
375        rt.handle_command(HostCommand::Run {
376            input: "readonly X=locked".into(),
377        });
378        let events = rt.handle_command(HostCommand::Run {
379            input: "echo $X".into(),
380        });
381        assert_eq!(get_stdout(&events), "locked\n");
382    }
383
384    #[test]
385    fn pipeline_last_status() {
386        // Pipeline exit status should be the last command's status
387        let (_, status) = run_shell("true | false");
388        assert_eq!(status, 1);
389        let (_, status) = run_shell("false | true");
390        assert_eq!(status, 0);
391    }
392
393    #[test]
394    fn pipe_data_flows_through() {
395        let (events, status) = run_shell("echo hello | cat");
396        assert_eq!(status, 0);
397        assert_eq!(get_stdout(&events), "hello\n");
398    }
399
400    #[test]
401    fn command_substitution_captures_stdout_without_leak() {
402        let (events, status) = run_shell("echo $(printf 'hello')");
403        assert_eq!(status, 0);
404        assert_eq!(get_stdout(&events), "hello\n");
405    }
406
407    #[test]
408    fn command_substitution_preserves_inner_stderr_visibility() {
409        let (events, status) = run_shell("echo $(printf 'hello'; echo err >&2)");
410        assert_eq!(status, 0);
411        assert_eq!(get_stdout(&events), "hello\n");
412        assert_eq!(get_stderr(&events), "err\n");
413    }
414
415    #[test]
416    fn command_substitution_isolates_shell_state() {
417        let (events, status) = run_shell("foo=before; echo $(foo=after; printf hi); echo $foo");
418        assert_eq!(status, 0);
419        assert_eq!(get_stdout(&events), "hi\nbefore\n");
420    }
421
422    #[test]
423    fn scheduler_executes_single_redirect_only_command() {
424        let mut rt = WorkerRuntime::new();
425        rt.handle_command(HostCommand::Init {
426            step_budget: 0,
427            allowed_hosts: vec![],
428        });
429        let events = rt.handle_command(HostCommand::Run {
430            input: "> /created.txt".into(),
431        });
432        let status = events
433            .iter()
434            .find_map(|e| {
435                if let WorkerEvent::Exit(s) = e {
436                    Some(*s)
437                } else {
438                    None
439                }
440            })
441            .unwrap_or(-1);
442        assert_eq!(status, 0);
443        assert_eq!(get_stdout(&events), "");
444        assert_eq!(get_stderr(&events), "");
445        let read_events = rt.handle_command(HostCommand::ReadFile {
446            path: "/created.txt".into(),
447        });
448        assert_eq!(get_stdout(&read_events), "");
449    }
450
451    #[test]
452    fn pipe_three_stages() {
453        let (events, status) = run_shell("echo hello world | cat | cat");
454        assert_eq!(status, 0);
455        assert_eq!(get_stdout(&events), "hello world\n");
456    }
457
458    #[test]
459    fn pipe_echo_to_wc() {
460        let (events, status) = run_shell("echo hello world | wc");
461        assert_eq!(status, 0);
462        let stdout = get_stdout(&events);
463        assert!(stdout.contains('1')); // 1 line
464        assert!(stdout.contains('2')); // 2 words
465    }
466
467    #[test]
468    fn streaming_yes_head_stops_after_requested_lines() {
469        let (events, status) = run_shell("yes | head -n 5");
470        assert_eq!(status, 0);
471        assert_eq!(get_stdout(&events), "y\ny\ny\ny\ny\n");
472    }
473
474    #[test]
475    fn streaming_yes_cat_head_stops_after_requested_lines() {
476        let (events, status) = run_shell("yes | cat | head -n 5");
477        assert_eq!(status, 0);
478        assert_eq!(get_stdout(&events), "y\ny\ny\ny\ny\n");
479    }
480
481    #[test]
482    fn streaming_yes_head_wc_counts_lines() {
483        let (events, status) = run_shell("yes | head -n 5 | wc -l");
484        assert_eq!(status, 0);
485        assert_eq!(get_stdout(&events), "5\n");
486    }
487
488    #[test]
489    fn streaming_cat_file_head_stops_at_requested_bytes() {
490        let mut rt = WorkerRuntime::new();
491        rt.handle_command(HostCommand::Init {
492            step_budget: 0,
493            allowed_hosts: vec![],
494        });
495        rt.handle_command(HostCommand::WriteFile {
496            path: "/big.txt".into(),
497            data: b"abcdefghijklmnopqrstuvwxyz".to_vec(),
498        });
499        let events = rt.handle_command(HostCommand::Run {
500            input: "cat /big.txt | head -c 10".into(),
501        });
502        let status = events
503            .iter()
504            .find_map(|e| {
505                if let WorkerEvent::Exit(s) = e {
506                    Some(*s)
507                } else {
508                    None
509                }
510            })
511            .unwrap_or(-1);
512        assert_eq!(status, 0);
513        assert_eq!(get_stdout(&events), "abcdefghij");
514    }
515
516    #[test]
517    fn streaming_yes_tr_head_transforms_lines() {
518        let (events, status) = run_shell("yes | tr y z | head -n 5");
519        assert_eq!(status, 0);
520        assert_eq!(get_stdout(&events), "z\nz\nz\nz\nz\n");
521    }
522
523    #[test]
524    fn streaming_yes_grep_head_stops_after_requested_lines() {
525        let (events, status) = run_shell("yes | grep y | head -n 5");
526        assert_eq!(status, 0);
527        assert_eq!(get_stdout(&events), "y\ny\ny\ny\ny\n");
528    }
529
530    #[test]
531    fn streaming_yes_tee_head_writes_only_pulled_output() {
532        let mut rt = WorkerRuntime::new();
533        rt.handle_command(HostCommand::Init {
534            step_budget: 0,
535            allowed_hosts: vec![],
536        });
537        let events = rt.handle_command(HostCommand::Run {
538            input: "yes | tee /tee.txt | head -n 5".into(),
539        });
540        let status = events
541            .iter()
542            .find_map(|event| {
543                if let WorkerEvent::Exit(code) = event {
544                    Some(*code)
545                } else {
546                    None
547                }
548            })
549            .unwrap_or(-1);
550        assert_eq!(status, 0);
551        assert_eq!(get_stdout(&events), "y\ny\ny\ny\ny\n");
552
553        let file_events = rt.handle_command(HostCommand::ReadFile {
554            path: "/tee.txt".into(),
555        });
556        assert_eq!(get_stdout(&file_events), "y\ny\ny\ny\ny\n");
557    }
558
559    #[test]
560    fn streaming_buffered_sort_tee_cat_preserves_sorted_output() {
561        let mut rt = WorkerRuntime::new();
562        rt.handle_command(HostCommand::Init {
563            step_budget: 0,
564            allowed_hosts: vec![],
565        });
566        let events = rt.handle_command(HostCommand::Run {
567            input: "printf 'b\\na\\n' | sort | tee /sorted.txt | cat".into(),
568        });
569        let status = events
570            .iter()
571            .find_map(|event| {
572                if let WorkerEvent::Exit(code) = event {
573                    Some(*code)
574                } else {
575                    None
576                }
577            })
578            .unwrap_or(-1);
579        assert_eq!(status, 0);
580        assert_eq!(get_stdout(&events), "a\nb\n");
581
582        let file_events = rt.handle_command(HostCommand::ReadFile {
583            path: "/sorted.txt".into(),
584        });
585        assert_eq!(get_stdout(&file_events), "a\nb\n");
586    }
587
588    #[test]
589    fn streaming_yes_rev_head_stops_after_requested_lines() {
590        let (events, status) = run_shell("yes | rev | head -n 5");
591        assert_eq!(status, 0);
592        assert_eq!(get_stdout(&events), "y\ny\ny\ny\ny\n");
593    }
594
595    #[test]
596    fn streaming_echo_cut_selects_field() {
597        let (events, status) = run_shell("echo abc:def | cut -d: -f2 | head -c 4");
598        assert_eq!(status, 0);
599        assert_eq!(get_stdout(&events), "def\n");
600    }
601
602    #[test]
603    fn streaming_echo_tail_head_selects_last_lines() {
604        let (events, status) = run_shell("echo -e 'a\\nb\\nc' | tail -n 2 | head -n 1");
605        assert_eq!(status, 0);
606        assert_eq!(get_stdout(&events), "b\n");
607    }
608
609    #[test]
610    fn streaming_buffered_printf_sort_head_outputs_sorted_first_line() {
611        let (events, status) = run_shell("printf 'b\\na\\n' | sort | head -n 1");
612        assert_eq!(status, 0);
613        assert_eq!(get_stdout(&events), "a\n");
614    }
615
616    #[test]
617    fn streaming_buffered_function_stage_preserves_output() {
618        let (events, status) = run_shell("f(){ cat; }\nprintf hi | f | head -c 2");
619        assert_eq!(status, 0);
620        assert_eq!(get_stdout(&events), "hi");
621    }
622
623    #[test]
624    fn streaming_buffered_function_pipe_stderr_preserves_output() {
625        let (events, status) = run_shell("f(){ echo out; echo err >&2; }\nf |& head -n 2");
626        assert_eq!(status, 0);
627        assert_eq!(get_stdout(&events), "out\nerr\n");
628    }
629
630    #[test]
631    fn scheduled_group_stage_pipe_stderr_preserves_output() {
632        let (events, status) = run_shell("printf x | { cat; echo err >&2; } |& cat");
633        assert_eq!(status, 0);
634        let stdout = get_stdout(&events);
635        assert!(stdout.contains('x'));
636        assert!(stdout.contains("err"));
637    }
638
639    #[test]
640    fn streaming_tee_pipe_stderr_preserves_output() {
641        let (events, status) = run_shell("printf x | tee / |& cat");
642        assert_eq!(status, 0);
643        let stdout = get_stdout(&events);
644        assert!(stdout.contains('x'));
645        assert!(stdout.contains("tee: /: is a directory: /"));
646        assert_eq!(get_stderr(&events), "");
647    }
648
649    #[test]
650    fn streaming_tee_pipe_stderr_respects_pipefail_status() {
651        let (events, status) = run_shell("set -o pipefail\nprintf x | tee / |& cat");
652        assert_eq!(status, 1);
653        let stdout = get_stdout(&events);
654        assert!(stdout.contains('x'));
655        assert!(stdout.contains("tee: /: is a directory: /"));
656        assert_eq!(get_stderr(&events), "");
657    }
658
659    #[test]
660    fn streaming_yes_bat_head_formats_numbered_lines() {
661        let (events, status) = run_shell("yes | bat --style=numbers | head -n 2");
662        assert_eq!(status, 0);
663        assert_eq!(get_stdout(&events), "    1   │ y\n    2   │ y\n");
664    }
665
666    #[test]
667    fn streaming_yes_sed_head_rewrites_lines() {
668        let (events, status) = run_shell("yes | sed 's/y/z/' | head -n 5");
669        assert_eq!(status, 0);
670        assert_eq!(get_stdout(&events), "z\nz\nz\nz\nz\n");
671    }
672
673    #[test]
674    fn streaming_echo_paste_serial_joins_lines() {
675        let (events, status) = run_shell("echo -e 'a\\nb\\nc' | paste -s -d , | head -c 6");
676        assert_eq!(status, 0);
677        assert_eq!(get_stdout(&events), "a,b,c\n");
678    }
679
680    #[test]
681    fn streaming_echo_column_preserves_plain_output() {
682        let (events, status) = run_shell("echo abc | column | head -c 4");
683        assert_eq!(status, 0);
684        assert_eq!(get_stdout(&events), "abc\n");
685    }
686
687    #[test]
688    fn streaming_echo_uniq_deduplicates_lines() {
689        let (events, status) = run_shell("echo -e 'a\\na\\nb' | uniq | head -n 2");
690        assert_eq!(status, 0);
691        assert_eq!(get_stdout(&events), "a\nb\n");
692    }
693
694    #[test]
695    fn generic_pipeline_grep_preserves_visible_output_budget_behavior() {
696        let (events, status) = run_shell("echo -e 'a\\nb' | grep b");
697        assert_eq!(status, 0);
698        assert_eq!(get_stdout(&events), "b\n");
699    }
700
701    #[test]
702    fn while_loop_with_counter() {
703        let mut rt = WorkerRuntime::new();
704        rt.handle_command(HostCommand::Init {
705            step_budget: 10000,
706            allowed_hosts: vec![],
707        });
708        // Simple loop that echoes 3 times using a counter variable
709        let events = rt.handle_command(HostCommand::Run {
710            input: "for i in 1 2 3; do echo line; done".into(),
711        });
712        assert_eq!(get_stdout(&events), "line\nline\nline\n");
713    }
714
715    #[test]
716    fn heredoc_with_cat() {
717        let mut rt = WorkerRuntime::new();
718        rt.handle_command(HostCommand::Init {
719            step_budget: 0,
720            allowed_hosts: vec![],
721        });
722        let events = rt.handle_command(HostCommand::Run {
723            input: "cat <<EOF\nhello world\nEOF\n".into(),
724        });
725        assert_eq!(get_stdout(&events), "hello world\n");
726    }
727
728    #[test]
729    fn string_length_expansion() {
730        let (events, status) = run_shell("X=hello; echo ${#X}");
731        assert_eq!(status, 0);
732        assert_eq!(get_stdout(&events), "5\n");
733    }
734
735    // ---- Functions ----
736
737    #[test]
738    fn function_define_and_call() {
739        let (events, status) = run_shell("greet() { echo hello; }; greet");
740        assert_eq!(status, 0);
741        assert_eq!(get_stdout(&events), "hello\n");
742    }
743
744    #[test]
745    fn function_with_args() {
746        let mut rt = WorkerRuntime::new();
747        rt.handle_command(HostCommand::Init {
748            step_budget: 0,
749            allowed_hosts: vec![],
750        });
751        rt.handle_command(HostCommand::Run {
752            input: "greet() { echo hello $1; }".into(),
753        });
754        let events = rt.handle_command(HostCommand::Run {
755            input: "greet world".into(),
756        });
757        assert_eq!(get_stdout(&events), "hello world\n");
758    }
759
760    #[test]
761    fn function_modifies_parent_scope() {
762        // Bash behavior: functions share parent scope (no isolation by default)
763        let mut rt = WorkerRuntime::new();
764        rt.handle_command(HostCommand::Init {
765            step_budget: 0,
766            allowed_hosts: vec![],
767        });
768        rt.handle_command(HostCommand::Run {
769            input: "X=outer".into(),
770        });
771        rt.handle_command(HostCommand::Run {
772            input: "f() { X=inner; }".into(),
773        });
774        rt.handle_command(HostCommand::Run { input: "f".into() });
775        let events = rt.handle_command(HostCommand::Run {
776            input: "echo $X".into(),
777        });
778        assert_eq!(get_stdout(&events), "inner\n");
779    }
780
781    #[test]
782    fn local_isolates_in_function() {
783        // `local` creates a variable that is restored after function returns
784        let mut rt = WorkerRuntime::new();
785        rt.handle_command(HostCommand::Init {
786            step_budget: 0,
787            allowed_hosts: vec![],
788        });
789        rt.handle_command(HostCommand::Run {
790            input: "X=outer".into(),
791        });
792        rt.handle_command(HostCommand::Run {
793            input: "f() { local X=inner; echo $X; }".into(),
794        });
795        let events = rt.handle_command(HostCommand::Run {
796            input: "f; echo $X".into(),
797        });
798        assert_eq!(get_stdout(&events), "inner\nouter\n");
799    }
800
801    // ---- Case ----
802
803    #[test]
804    fn case_basic() {
805        let source = "case hello in\nhello) echo matched;;\nworld) echo no;;\nesac";
806        let (events, status) = run_shell(source);
807        assert_eq!(status, 0);
808        assert_eq!(get_stdout(&events), "matched\n");
809    }
810
811    #[test]
812    fn case_wildcard() {
813        let source = "case anything in\n*) echo default;;\nesac";
814        let (events, _) = run_shell(source);
815        assert_eq!(get_stdout(&events), "default\n");
816    }
817
818    #[test]
819    fn case_no_match() {
820        let source = "case hello in\nworld) echo no;;\nesac";
821        let (events, _) = run_shell(source);
822        assert_eq!(get_stdout(&events), "");
823    }
824
825    // ---- Subshell scope isolation ----
826
827    #[test]
828    fn subshell_scope_isolation() {
829        let mut rt = WorkerRuntime::new();
830        rt.handle_command(HostCommand::Init {
831            step_budget: 0,
832            allowed_hosts: vec![],
833        });
834        rt.handle_command(HostCommand::Run {
835            input: "X=outer".into(),
836        });
837        rt.handle_command(HostCommand::Run {
838            input: "(X=inner)".into(),
839        });
840        let events = rt.handle_command(HostCommand::Run {
841            input: "echo $X".into(),
842        });
843        assert_eq!(get_stdout(&events), "outer\n");
844    }
845
846    // ---- Assign-default expansion ----
847
848    #[test]
849    fn assign_default_expansion() {
850        let (events, _) = run_shell("echo ${X:=fallback}; echo $X");
851        assert_eq!(get_stdout(&events), "fallback\nfallback\n");
852    }
853
854    // ---- Glob expansion ----
855
856    #[test]
857    fn glob_star_matches_files() {
858        let mut rt = WorkerRuntime::new();
859        rt.handle_command(HostCommand::Init {
860            step_budget: 0,
861            allowed_hosts: vec![],
862        });
863        rt.handle_command(HostCommand::Run {
864            input: "touch /a.txt".into(),
865        });
866        rt.handle_command(HostCommand::Run {
867            input: "touch /b.txt".into(),
868        });
869        rt.handle_command(HostCommand::Run {
870            input: "touch /c.log".into(),
871        });
872        let events = rt.handle_command(HostCommand::Run {
873            input: "echo /*.txt".into(),
874        });
875        let stdout = get_stdout(&events);
876        assert!(stdout.contains("/a.txt"));
877        assert!(stdout.contains("/b.txt"));
878        assert!(!stdout.contains("c.log"));
879    }
880
881    #[test]
882    fn glob_no_match_keeps_literal() {
883        let (events, _) = run_shell("echo /no_such_*.xyz");
884        assert_eq!(get_stdout(&events), "/no_such_*.xyz\n");
885    }
886
887    #[test]
888    fn glob_question_mark() {
889        let mut rt = WorkerRuntime::new();
890        rt.handle_command(HostCommand::Init {
891            step_budget: 0,
892            allowed_hosts: vec![],
893        });
894        rt.handle_command(HostCommand::Run {
895            input: "touch /ab".into(),
896        });
897        rt.handle_command(HostCommand::Run {
898            input: "touch /ac".into(),
899        });
900        rt.handle_command(HostCommand::Run {
901            input: "touch /abc".into(),
902        });
903        let events = rt.handle_command(HostCommand::Run {
904            input: "echo /a?".into(),
905        });
906        let stdout = get_stdout(&events);
907        assert!(stdout.contains("/ab"));
908        assert!(stdout.contains("/ac"));
909        assert!(!stdout.contains("/abc"));
910    }
911
912    // ---- Brace expansion ----
913
914    #[test]
915    fn brace_comma_expansion() {
916        let (events, _) = run_shell("echo {a,b,c}");
917        assert_eq!(get_stdout(&events), "a b c\n");
918    }
919
920    #[test]
921    fn brace_range_expansion() {
922        let (events, _) = run_shell("echo {1..5}");
923        assert_eq!(get_stdout(&events), "1 2 3 4 5\n");
924    }
925
926    #[test]
927    fn brace_prefix_suffix() {
928        let (events, _) = run_shell("echo file{1,2,3}.txt");
929        assert_eq!(get_stdout(&events), "file1.txt file2.txt file3.txt\n");
930    }
931
932    // ---- Here-string ----
933
934    #[test]
935    fn here_string_basic() {
936        let (events, status) = run_shell("cat <<< hello");
937        assert_eq!(status, 0);
938        assert_eq!(get_stdout(&events), "hello\n");
939    }
940
941    #[test]
942    fn here_string_with_variable() {
943        let (events, status) = run_shell("X=world; cat <<< $X");
944        assert_eq!(status, 0);
945        assert_eq!(get_stdout(&events), "world\n");
946    }
947
948    // ---- ANSI-C quoting ----
949
950    #[test]
951    fn ansi_c_quoting_newline() {
952        let (events, status) = run_shell("echo $'hello\\nworld'");
953        assert_eq!(status, 0);
954        assert_eq!(get_stdout(&events), "hello\nworld\n");
955    }
956
957    #[test]
958    fn ansi_c_quoting_tab() {
959        let (events, status) = run_shell("echo $'a\\tb'");
960        assert_eq!(status, 0);
961        assert_eq!(get_stdout(&events), "a\tb\n");
962    }
963
964    #[test]
965    fn ansi_c_quoting_hex() {
966        let (events, status) = run_shell("echo $'\\x41'");
967        assert_eq!(status, 0);
968        assert_eq!(get_stdout(&events), "A\n");
969    }
970
971    // ---- Stderr redirection ----
972
973    #[test]
974    fn stderr_redirect_to_file() {
975        let mut rt = WorkerRuntime::new();
976        rt.handle_command(HostCommand::Init {
977            step_budget: 0,
978            allowed_hosts: vec![],
979        });
980        // Running a command that doesn't exist produces stderr
981        let _events = rt.handle_command(HostCommand::Run {
982            input: "nonexistent_cmd 2> /err.txt".into(),
983        });
984        // stderr should have been captured to file
985        let read_events = rt.handle_command(HostCommand::ReadFile {
986            path: "/err.txt".into(),
987        });
988        let err_content = get_stdout(&read_events);
989        assert!(err_content.contains("command not found"));
990    }
991
992    #[test]
993    fn stderr_merge_into_stdout() {
994        let mut rt = WorkerRuntime::new();
995        rt.handle_command(HostCommand::Init {
996            step_budget: 0,
997            allowed_hosts: vec![],
998        });
999        // Redirections are applied left-to-right: stderr duplicates the original
1000        // stdout, then stdout is redirected to the file. The error stays visible.
1001        let events = rt.handle_command(HostCommand::Run {
1002            input: "nonexistent_cmd 2>&1 > /out.txt".into(),
1003        });
1004        let read_events = rt.handle_command(HostCommand::ReadFile {
1005            path: "/out.txt".into(),
1006        });
1007        let content = get_stdout(&read_events);
1008        assert_eq!(content, "");
1009        assert!(get_stdout(&events).contains("command not found"));
1010        assert_eq!(get_stderr(&events), "");
1011    }
1012
1013    #[test]
1014    fn amp_greater_both_to_file() {
1015        let mut rt = WorkerRuntime::new();
1016        rt.handle_command(HostCommand::Init {
1017            step_budget: 0,
1018            allowed_hosts: vec![],
1019        });
1020        let _events = rt.handle_command(HostCommand::Run {
1021            input: "nonexistent_cmd &> /all.txt".into(),
1022        });
1023        let read_events = rt.handle_command(HostCommand::ReadFile {
1024            path: "/all.txt".into(),
1025        });
1026        let content = get_stdout(&read_events);
1027        assert!(content.contains("command not found"));
1028    }
1029
1030    // ---- [[ ]] extended test ----
1031
1032    #[test]
1033    fn dbl_bracket_string_equality() {
1034        let (_, status) = run_shell("[[ hello == hello ]]");
1035        assert_eq!(status, 0);
1036        let (_, status) = run_shell("[[ hello == world ]]");
1037        assert_eq!(status, 1);
1038    }
1039
1040    #[test]
1041    fn dbl_bracket_string_inequality() {
1042        let (_, status) = run_shell("[[ hello != world ]]");
1043        assert_eq!(status, 0);
1044        let (_, status) = run_shell("[[ hello != hello ]]");
1045        assert_eq!(status, 1);
1046    }
1047
1048    #[test]
1049    fn dbl_bracket_glob_match() {
1050        let (_, status) = run_shell("[[ hello == hel* ]]");
1051        assert_eq!(status, 0);
1052        let (_, status) = run_shell("[[ hello == wor* ]]");
1053        assert_eq!(status, 1);
1054    }
1055
1056    #[test]
1057    fn dbl_bracket_string_ordering() {
1058        let (_, status) = run_shell("[[ abc < def ]]");
1059        assert_eq!(status, 0);
1060        let (_, status) = run_shell("[[ def < abc ]]");
1061        assert_eq!(status, 1);
1062        let (_, status) = run_shell("[[ def > abc ]]");
1063        assert_eq!(status, 0);
1064    }
1065
1066    #[test]
1067    fn dbl_bracket_integer_comparison() {
1068        let (_, status) = run_shell("[[ 5 -eq 5 ]]");
1069        assert_eq!(status, 0);
1070        let (_, status) = run_shell("[[ 5 -ne 3 ]]");
1071        assert_eq!(status, 0);
1072        let (_, status) = run_shell("[[ 3 -lt 5 ]]");
1073        assert_eq!(status, 0);
1074        let (_, status) = run_shell("[[ 5 -le 5 ]]");
1075        assert_eq!(status, 0);
1076        let (_, status) = run_shell("[[ 7 -gt 3 ]]");
1077        assert_eq!(status, 0);
1078        let (_, status) = run_shell("[[ 5 -ge 5 ]]");
1079        assert_eq!(status, 0);
1080        let (_, status) = run_shell("[[ 5 -lt 3 ]]");
1081        assert_eq!(status, 1);
1082    }
1083
1084    #[test]
1085    fn dbl_bracket_string_tests() {
1086        let (_, status) = run_shell("[[ -z \"\" ]]");
1087        assert_eq!(status, 0);
1088        let (_, status) = run_shell("[[ -z hello ]]");
1089        assert_eq!(status, 1);
1090        let (_, status) = run_shell("[[ -n hello ]]");
1091        assert_eq!(status, 0);
1092        let (_, status) = run_shell("[[ -n \"\" ]]");
1093        assert_eq!(status, 1);
1094    }
1095
1096    #[test]
1097    fn dbl_bracket_logical_and() {
1098        let (_, status) = run_shell("[[ hello == hello && world == world ]]");
1099        assert_eq!(status, 0);
1100        let (_, status) = run_shell("[[ hello == hello && world == nope ]]");
1101        assert_eq!(status, 1);
1102    }
1103
1104    #[test]
1105    fn dbl_bracket_logical_or() {
1106        let (_, status) = run_shell("[[ hello == nope || world == world ]]");
1107        assert_eq!(status, 0);
1108        let (_, status) = run_shell("[[ hello == nope || world == nope ]]");
1109        assert_eq!(status, 1);
1110    }
1111
1112    #[test]
1113    fn dbl_bracket_logical_not() {
1114        let (_, status) = run_shell("[[ ! hello == world ]]");
1115        assert_eq!(status, 0);
1116        let (_, status) = run_shell("[[ ! hello == hello ]]");
1117        assert_eq!(status, 1);
1118    }
1119
1120    #[test]
1121    fn dbl_bracket_variable_expansion() {
1122        let (_, status) = run_shell("X=hello; [[ $X == hello ]]");
1123        assert_eq!(status, 0);
1124        let (_, status) = run_shell("X=hello; [[ $X == world ]]");
1125        assert_eq!(status, 1);
1126    }
1127
1128    #[test]
1129    fn dbl_bracket_no_word_splitting() {
1130        // In [[ ]], variables with spaces should NOT be word-split
1131        let (_, status) = run_shell("X=\"hello world\"; [[ $X == \"hello world\" ]]");
1132        assert_eq!(status, 0);
1133    }
1134
1135    #[test]
1136    fn dbl_bracket_file_tests() {
1137        let mut rt = WorkerRuntime::new();
1138        rt.handle_command(HostCommand::Init {
1139            step_budget: 0,
1140            allowed_hosts: vec![],
1141        });
1142        // Create a file
1143        rt.handle_command(HostCommand::Run {
1144            input: "touch /testfile".into(),
1145        });
1146        // -e: file exists
1147        let events = rt.handle_command(HostCommand::Run {
1148            input: "[[ -e /testfile ]]".into(),
1149        });
1150        let status = events
1151            .iter()
1152            .find_map(|e| {
1153                if let WorkerEvent::Exit(s) = e {
1154                    Some(*s)
1155                } else {
1156                    None
1157                }
1158            })
1159            .unwrap();
1160        assert_eq!(status, 0);
1161
1162        // -f: is a regular file
1163        let events = rt.handle_command(HostCommand::Run {
1164            input: "[[ -f /testfile ]]".into(),
1165        });
1166        let status = events
1167            .iter()
1168            .find_map(|e| {
1169                if let WorkerEvent::Exit(s) = e {
1170                    Some(*s)
1171                } else {
1172                    None
1173                }
1174            })
1175            .unwrap();
1176        assert_eq!(status, 0);
1177
1178        // -d: is a directory (should fail for a file)
1179        let events = rt.handle_command(HostCommand::Run {
1180            input: "[[ -d /testfile ]]".into(),
1181        });
1182        let status = events
1183            .iter()
1184            .find_map(|e| {
1185                if let WorkerEvent::Exit(s) = e {
1186                    Some(*s)
1187                } else {
1188                    None
1189                }
1190            })
1191            .unwrap();
1192        assert_eq!(status, 1);
1193
1194        // -e: non-existent file
1195        let events = rt.handle_command(HostCommand::Run {
1196            input: "[[ -e /nonexistent ]]".into(),
1197        });
1198        let status = events
1199            .iter()
1200            .find_map(|e| {
1201                if let WorkerEvent::Exit(s) = e {
1202                    Some(*s)
1203                } else {
1204                    None
1205                }
1206            })
1207            .unwrap();
1208        assert_eq!(status, 1);
1209    }
1210
1211    #[test]
1212    fn dbl_bracket_dir_test() {
1213        let mut rt = WorkerRuntime::new();
1214        rt.handle_command(HostCommand::Init {
1215            step_budget: 0,
1216            allowed_hosts: vec![],
1217        });
1218        rt.handle_command(HostCommand::Run {
1219            input: "mkdir /testdir".into(),
1220        });
1221        let events = rt.handle_command(HostCommand::Run {
1222            input: "[[ -d /testdir ]]".into(),
1223        });
1224        let status = events
1225            .iter()
1226            .find_map(|e| {
1227                if let WorkerEvent::Exit(s) = e {
1228                    Some(*s)
1229                } else {
1230                    None
1231                }
1232            })
1233            .unwrap();
1234        assert_eq!(status, 0);
1235    }
1236
1237    #[test]
1238    fn dbl_bracket_regex_match() {
1239        let (_, status) = run_shell("[[ hello =~ ^hel ]]");
1240        assert_eq!(status, 0);
1241        let (_, status) = run_shell("[[ hello =~ world ]]");
1242        assert_eq!(status, 1);
1243        let (_, status) = run_shell("[[ hello =~ ^hello$ ]]");
1244        assert_eq!(status, 0);
1245    }
1246
1247    #[test]
1248    fn dbl_bracket_in_if() {
1249        let (events, status) = run_shell("if [[ 1 -eq 1 ]]; then echo yes; fi");
1250        assert_eq!(status, 0);
1251        assert_eq!(get_stdout(&events), "yes\n");
1252    }
1253
1254    #[test]
1255    fn dbl_bracket_in_and_or() {
1256        let (events, _) = run_shell("[[ hello == hello ]] && echo matched");
1257        assert_eq!(get_stdout(&events), "matched\n");
1258        let (events, _) = run_shell("[[ hello == nope ]] || echo fallback");
1259        assert_eq!(get_stdout(&events), "fallback\n");
1260    }
1261
1262    #[test]
1263    fn dbl_bracket_grouping() {
1264        let (_, status) = run_shell("[[ ( hello == hello ) ]]");
1265        assert_eq!(status, 0);
1266        // Grouping with || inside ()
1267        let (_, status) = run_shell("[[ ( a == b || a == a ) && x == x ]]");
1268        assert_eq!(status, 0);
1269    }
1270
1271    #[test]
1272    fn dbl_bracket_single_string() {
1273        // Non-empty string is true
1274        let (_, status) = run_shell("[[ hello ]]");
1275        assert_eq!(status, 0);
1276        // Empty string is false
1277        let (_, status) = run_shell("[[ \"\" ]]");
1278        assert_eq!(status, 1);
1279    }
1280
1281    // ---- (( )) arithmetic command ----
1282
1283    #[test]
1284    fn arith_command_nonzero_is_success() {
1285        // (( 1 )) → non-zero result → exit 0
1286        let (_, status) = run_shell("(( 1 ))");
1287        assert_eq!(status, 0);
1288    }
1289
1290    #[test]
1291    fn arith_command_zero_is_failure() {
1292        // (( 0 )) → zero result → exit 1
1293        let (_, status) = run_shell("(( 0 ))");
1294        assert_eq!(status, 1);
1295    }
1296
1297    #[test]
1298    fn arith_command_expression() {
1299        let (_, status) = run_shell("(( 2 + 3 ))");
1300        assert_eq!(status, 0); // result 5 → non-zero → success
1301    }
1302
1303    #[test]
1304    fn arith_command_assignment() {
1305        let (events, _) = run_shell("(( x = 42 )); echo $x");
1306        assert_eq!(get_stdout(&events), "42\n");
1307    }
1308
1309    #[test]
1310    fn arith_command_in_if() {
1311        let (events, _) = run_shell("if (( 1 + 1 )); then echo yes; fi");
1312        assert_eq!(get_stdout(&events), "yes\n");
1313    }
1314
1315    #[test]
1316    fn arith_command_in_and_or() {
1317        let (events, _) = run_shell("(( 1 )) && echo ok");
1318        assert_eq!(get_stdout(&events), "ok\n");
1319        let (events, _) = run_shell("(( 0 )) || echo fallback");
1320        assert_eq!(get_stdout(&events), "fallback\n");
1321    }
1322
1323    #[test]
1324    fn arith_command_increment() {
1325        let (events, _) = run_shell("x=5; (( x++ )); echo $x");
1326        assert_eq!(get_stdout(&events), "6\n");
1327    }
1328
1329    // ---- C-style for (( )) loop ----
1330
1331    #[test]
1332    fn arith_for_basic() {
1333        let (events, status) = run_shell("for ((i=0; i<5; i++)) do echo $i; done");
1334        assert_eq!(status, 0);
1335        assert_eq!(get_stdout(&events), "0\n1\n2\n3\n4\n");
1336    }
1337
1338    #[test]
1339    fn arith_for_with_spaces() {
1340        let (events, _) = run_shell("for (( i = 0; i < 3; i++ )) do echo $i; done");
1341        assert_eq!(get_stdout(&events), "0\n1\n2\n");
1342    }
1343
1344    #[test]
1345    fn arith_for_sum() {
1346        let (events, _) =
1347            run_shell("sum=0; for ((i=1; i<=10; i++)) do (( sum += i )); done; echo $sum");
1348        assert_eq!(get_stdout(&events), "55\n");
1349    }
1350
1351    #[test]
1352    fn arith_for_break() {
1353        let (events, _) =
1354            run_shell("for ((i=0; i<100; i++)) do if (( i == 3 )); then break; fi; echo $i; done");
1355        assert_eq!(get_stdout(&events), "0\n1\n2\n");
1356    }
1357
1358    #[test]
1359    fn arith_for_continue() {
1360        let (events, _) =
1361            run_shell("for ((i=0; i<5; i++)) do if (( i == 2 )); then continue; fi; echo $i; done");
1362        assert_eq!(get_stdout(&events), "0\n1\n3\n4\n");
1363    }
1364
1365    // ---- let builtin ----
1366
1367    #[test]
1368    fn let_basic_assignment() {
1369        let (events, _) = run_shell("let x=5; echo $x");
1370        assert_eq!(get_stdout(&events), "5\n");
1371    }
1372
1373    #[test]
1374    fn let_arithmetic() {
1375        let (events, _) = run_shell("let x=2+3; echo $x");
1376        assert_eq!(get_stdout(&events), "5\n");
1377    }
1378
1379    #[test]
1380    fn let_returns_zero_for_nonzero() {
1381        // let returns 0 when last expression is non-zero
1382        let (_, status) = run_shell("let 1+1");
1383        assert_eq!(status, 0);
1384    }
1385
1386    #[test]
1387    fn let_returns_one_for_zero() {
1388        // let returns 1 when last expression is zero
1389        let (_, status) = run_shell("let 0");
1390        assert_eq!(status, 1);
1391    }
1392
1393    #[test]
1394    fn let_multiple_expressions() {
1395        let (events, status) = run_shell("let a=1 b=2 c=a+b; echo $c");
1396        assert_eq!(status, 0); // last expr (a+b=3) is non-zero → 0
1397        assert_eq!(get_stdout(&events), "3\n");
1398    }
1399
1400    #[test]
1401    fn let_no_args_fails() {
1402        let (_, status) = run_shell("let");
1403        assert_eq!(status, 1);
1404    }
1405
1406    // ---- declare/typeset ----
1407
1408    #[test]
1409    fn declare_basic_variable() {
1410        let (events, _) = run_shell("declare x=hello; echo $x");
1411        assert_eq!(get_stdout(&events), "hello\n");
1412    }
1413
1414    #[test]
1415    fn declare_integer_flag() {
1416        let (events, _) = run_shell("declare -i x=2+3; echo $x");
1417        assert_eq!(get_stdout(&events), "5\n");
1418    }
1419
1420    #[test]
1421    fn declare_export_flag() {
1422        let (events, _) = run_shell("declare -x MYVAR=exported; echo $MYVAR");
1423        assert_eq!(get_stdout(&events), "exported\n");
1424    }
1425
1426    #[test]
1427    fn declare_readonly_flag() {
1428        // After declare -r, re-assignment should be silently ignored
1429        let (events, _) = run_shell("declare -r X=locked; X=new; echo $X");
1430        assert_eq!(get_stdout(&events), "locked\n");
1431    }
1432
1433    #[test]
1434    fn declare_lowercase_flag() {
1435        let (events, _) = run_shell("declare -l x=HELLO; echo $x");
1436        assert_eq!(get_stdout(&events), "hello\n");
1437    }
1438
1439    #[test]
1440    fn declare_uppercase_flag() {
1441        let (events, _) = run_shell("declare -u x=hello; echo $x");
1442        assert_eq!(get_stdout(&events), "HELLO\n");
1443    }
1444
1445    #[test]
1446    fn declare_indexed_array() {
1447        let (events, _) = run_shell("declare -a arr; arr[0]=x; arr[1]=y; echo ${arr[0]} ${arr[1]}");
1448        assert_eq!(get_stdout(&events), "x y\n");
1449    }
1450
1451    #[test]
1452    fn declare_assoc_array() {
1453        let (events, _) = run_shell("declare -A map; map[key]=val; echo ${map[key]}");
1454        assert_eq!(get_stdout(&events), "val\n");
1455    }
1456
1457    #[test]
1458    fn typeset_is_alias_for_declare() {
1459        let (events, _) = run_shell("typeset -i x=3+4; echo $x");
1460        assert_eq!(get_stdout(&events), "7\n");
1461    }
1462
1463    #[test]
1464    fn declare_print_specific_var() {
1465        let (events, _) = run_shell("x=hello; declare -p x");
1466        let out = get_stdout(&events);
1467        assert!(out.contains("x="));
1468        assert!(out.contains("hello"));
1469    }
1470
1471    // ---- set -o / shell option enforcement tests ----
1472
1473    #[test]
1474    fn set_o_pipefail_enable_disable() {
1475        // set -o pipefail stores SHOPT_o_pipefail=1
1476        let (events, status) = run_shell("set -o pipefail; echo $SHOPT_o_pipefail");
1477        assert_eq!(status, 0);
1478        assert_eq!(get_stdout(&events), "1\n");
1479
1480        // set +o pipefail stores SHOPT_o_pipefail=0
1481        let (events, _) = run_shell("set -o pipefail; set +o pipefail; echo $SHOPT_o_pipefail");
1482        assert_eq!(get_stdout(&events), "0\n");
1483    }
1484
1485    #[test]
1486    fn pipefail_uses_rightmost_failure() {
1487        // Without pipefail: last command determines status
1488        let (_, status) = run_shell("false | true");
1489        assert_eq!(status, 0);
1490
1491        // With pipefail: rightmost non-zero status is used
1492        let (_, status) = run_shell("set -o pipefail; false | true");
1493        assert_eq!(status, 1);
1494    }
1495
1496    #[test]
1497    fn pipefail_all_succeed_is_zero() {
1498        let (_, status) = run_shell("set -o pipefail; true | true | true");
1499        assert_eq!(status, 0);
1500    }
1501
1502    #[test]
1503    fn pipefail_rightmost_nonzero() {
1504        // The rightmost non-zero should be chosen
1505        let (_, status) = run_shell("set -o pipefail; false | true | false");
1506        assert_eq!(status, 1);
1507    }
1508
1509    #[test]
1510    fn nounset_unset_var_errors() {
1511        let (events, status) = run_shell("set -u; echo $UNSET_VAR");
1512        assert_eq!(status, 1);
1513        let stderr = get_stderr(&events);
1514        assert!(stderr.contains("UNSET_VAR"));
1515        assert!(stderr.contains("unbound variable"));
1516    }
1517
1518    #[test]
1519    fn nounset_set_var_ok() {
1520        // set -u should not trigger for defined variables
1521        let (events, status) = run_shell("set -u; X=hello; echo $X");
1522        assert_eq!(status, 0);
1523        assert_eq!(get_stdout(&events), "hello\n");
1524    }
1525
1526    #[test]
1527    fn nounset_special_params_ok() {
1528        // $? and $# should not trigger nounset
1529        let (events, status) = run_shell("set -u; echo $? $#");
1530        assert_eq!(status, 0);
1531        assert_eq!(get_stdout(&events), "0 0\n");
1532    }
1533
1534    #[test]
1535    fn nounset_with_default_operator() {
1536        // ${var:-default} should not trigger nounset even when var is unset
1537        let (events, status) = run_shell("set -u; echo ${UNSET:-fallback}");
1538        assert_eq!(status, 0);
1539        assert_eq!(get_stdout(&events), "fallback\n");
1540    }
1541
1542    #[test]
1543    fn nounset_long_option_alias() {
1544        // set -o nounset should be equivalent to set -u
1545        let (events, status) = run_shell("set -o nounset; echo $UNSET_VAR");
1546        assert_eq!(status, 1);
1547        let stderr = get_stderr(&events);
1548        assert!(stderr.contains("unbound variable"));
1549    }
1550
1551    #[test]
1552    fn xtrace_outputs_commands() {
1553        let (events, status) = run_shell("set -x; echo hello");
1554        assert_eq!(status, 0);
1555        assert_eq!(get_stdout(&events), "hello\n");
1556        let stderr = get_stderr(&events);
1557        // xtrace should produce "+ echo hello" on stderr
1558        assert!(stderr.contains("+ echo hello"));
1559    }
1560
1561    #[test]
1562    fn xtrace_custom_ps4() {
1563        let (events, _) = run_shell("PS4='>> '; set -x; echo test");
1564        let stderr = get_stderr(&events);
1565        assert!(stderr.contains(">> echo test"));
1566    }
1567
1568    #[test]
1569    fn xtrace_disabled_with_plus_x() {
1570        let (events, _) = run_shell("set -x; set +x; echo quiet");
1571        let stderr = get_stderr(&events);
1572        // The "set +x" itself is traced, but "echo quiet" should not be
1573        assert!(stderr.contains("+ set +x"));
1574        assert!(!stderr.contains("+ echo quiet"));
1575    }
1576
1577    #[test]
1578    fn noglob_skips_expansion() {
1579        let mut rt = WorkerRuntime::new();
1580        rt.handle_command(HostCommand::Init {
1581            step_budget: 0,
1582            allowed_hosts: vec![],
1583        });
1584        // Create a file that would match *.txt
1585        rt.handle_command(HostCommand::Run {
1586            input: "touch /hello.txt".into(),
1587        });
1588        // With noglob, the * should be literal
1589        let events = rt.handle_command(HostCommand::Run {
1590            input: "set -f; echo /*.txt".into(),
1591        });
1592        let stdout = get_stdout(&events);
1593        assert_eq!(stdout, "/*.txt\n");
1594    }
1595
1596    #[test]
1597    fn noglob_disabled_allows_expansion() {
1598        let mut rt = WorkerRuntime::new();
1599        rt.handle_command(HostCommand::Init {
1600            step_budget: 0,
1601            allowed_hosts: vec![],
1602        });
1603        rt.handle_command(HostCommand::Run {
1604            input: "touch /abc.txt".into(),
1605        });
1606        // Enable then disable noglob: globs should work again
1607        let events = rt.handle_command(HostCommand::Run {
1608            input: "set -f; set +f; echo /*.txt".into(),
1609        });
1610        let stdout = get_stdout(&events);
1611        assert_eq!(stdout, "/abc.txt\n");
1612    }
1613
1614    #[test]
1615    fn allexport_auto_exports() {
1616        let (events, status) = run_shell("set -a; MYVAR=hello; echo $MYVAR");
1617        assert_eq!(status, 0);
1618        assert_eq!(get_stdout(&events), "hello\n");
1619        // We can't directly test export flag from shell, but we can verify
1620        // via declare -p which shows flags. Or we simply verify the variable is set.
1621    }
1622
1623    #[test]
1624    fn set_long_options_errexit() {
1625        // set -o errexit should be same as set -e
1626        let (events, status) = run_shell("set -o errexit; echo $SHOPT_e");
1627        assert_eq!(status, 0);
1628        assert_eq!(get_stdout(&events), "1\n");
1629    }
1630
1631    #[test]
1632    fn set_long_options_xtrace() {
1633        let (events, _) = run_shell("set -o xtrace; echo $SHOPT_x");
1634        assert_eq!(get_stdout(&events), "1\n");
1635    }
1636
1637    #[test]
1638    fn set_long_options_allexport() {
1639        let (events, _) = run_shell("set -o allexport; echo $SHOPT_a");
1640        assert_eq!(get_stdout(&events), "1\n");
1641    }
1642
1643    #[test]
1644    fn set_long_options_noglob() {
1645        let (events, _) = run_shell("set -o noglob; echo $SHOPT_f");
1646        assert_eq!(get_stdout(&events), "1\n");
1647    }
1648
1649    #[test]
1650    fn set_long_options_noclobber() {
1651        let (events, _) = run_shell("set -o noclobber; echo $SHOPT_C");
1652        assert_eq!(get_stdout(&events), "1\n");
1653    }
1654
1655    #[test]
1656    fn set_dash_o_lists_known_options() {
1657        let (events, status) = run_shell("set -o errexit; set -o");
1658        assert_eq!(status, 0);
1659        let out = get_stdout(&events);
1660        assert!(out.contains("errexit"));
1661        assert!(out.contains("pipefail"));
1662        assert!(out.contains("verbose"));
1663        assert!(out
1664            .lines()
1665            .any(|line| line.starts_with("errexit") && line.ends_with("on")));
1666    }
1667
1668    #[test]
1669    fn set_plus_o_prints_recreatable_commands() {
1670        let (events, status) = run_shell("set -o errexit; set +o");
1671        assert_eq!(status, 0);
1672        let out = get_stdout(&events);
1673        assert!(out.contains("set -o errexit"));
1674        assert!(out.contains("set +o nounset"));
1675    }
1676
1677    #[test]
1678    fn set_updates_special_dash_flags() {
1679        let (events, status) = run_shell("set -E -T -p -v; echo $-");
1680        assert_eq!(status, 0);
1681        let out = get_stdout(&events);
1682        assert!(out.contains('E'));
1683        assert!(out.contains('T'));
1684        assert!(out.contains('p'));
1685        assert!(out.contains('v'));
1686    }
1687
1688    #[test]
1689    fn noexec_skips_subsequent_runs() {
1690        let mut rt = WorkerRuntime::new();
1691        rt.handle_command(HostCommand::Init {
1692            step_budget: 0,
1693            allowed_hosts: vec![],
1694        });
1695        let first = rt.handle_command(HostCommand::Run {
1696            input: "set -n".into(),
1697        });
1698        let first_status = first
1699            .iter()
1700            .find_map(|e| {
1701                if let WorkerEvent::Exit(s) = e {
1702                    Some(*s)
1703                } else {
1704                    None
1705                }
1706            })
1707            .unwrap_or(-1);
1708        assert_eq!(first_status, 0);
1709
1710        let second = rt.handle_command(HostCommand::Run {
1711            input: "echo skipped".into(),
1712        });
1713        let second_status = second
1714            .iter()
1715            .find_map(|e| {
1716                if let WorkerEvent::Exit(s) = e {
1717                    Some(*s)
1718                } else {
1719                    None
1720                }
1721            })
1722            .unwrap_or(-1);
1723        assert_eq!(second_status, 0);
1724        assert_eq!(get_stdout(&second), "");
1725    }
1726
1727    #[test]
1728    fn verbose_echoes_subsequent_runs() {
1729        let mut rt = WorkerRuntime::new();
1730        rt.handle_command(HostCommand::Init {
1731            step_budget: 0,
1732            allowed_hosts: vec![],
1733        });
1734        let first = rt.handle_command(HostCommand::Run {
1735            input: "set -v".into(),
1736        });
1737        let first_status = first
1738            .iter()
1739            .find_map(|e| {
1740                if let WorkerEvent::Exit(s) = e {
1741                    Some(*s)
1742                } else {
1743                    None
1744                }
1745            })
1746            .unwrap_or(-1);
1747        assert_eq!(first_status, 0);
1748
1749        let events = rt.handle_command(HostCommand::Run {
1750            input: "echo hello".into(),
1751        });
1752        let status = events
1753            .iter()
1754            .find_map(|e| {
1755                if let WorkerEvent::Exit(s) = e {
1756                    Some(*s)
1757                } else {
1758                    None
1759                }
1760            })
1761            .unwrap_or(-1);
1762        assert_eq!(status, 0);
1763        assert_eq!(get_stdout(&events), "hello\n");
1764        let stderr = get_stderr(&events);
1765        assert!(stderr.contains("echo hello"));
1766    }
1767
1768    // ---- shopt builtin tests ----
1769
1770    #[test]
1771    fn shopt_list_all() {
1772        let (events, status) = run_shell("shopt");
1773        assert_eq!(status, 0);
1774        let out = get_stdout(&events);
1775        assert!(out.contains("extglob"));
1776        assert!(out.contains("nullglob"));
1777        assert!(out.contains("dotglob"));
1778        assert!(out.contains("globstar"));
1779        assert!(out.contains("sourcepath"));
1780        assert!(out.contains("off"));
1781    }
1782
1783    #[test]
1784    fn shopt_enable_option() {
1785        let (events, status) = run_shell("shopt -s extglob; shopt extglob");
1786        assert_eq!(status, 0);
1787        let out = get_stdout(&events);
1788        assert!(out.contains("extglob\ton"));
1789    }
1790
1791    #[test]
1792    fn shopt_disable_option() {
1793        let (events, status) = run_shell("shopt -s extglob; shopt -u extglob; shopt extglob");
1794        assert_eq!(status, 0);
1795        let out = get_stdout(&events);
1796        assert!(out.contains("extglob\toff"));
1797    }
1798
1799    #[test]
1800    fn shopt_invalid_option() {
1801        let (events, status) = run_shell("shopt -s nonexistent");
1802        assert_eq!(status, 1);
1803        let stderr = get_stderr(&events);
1804        assert!(stderr.contains("invalid shell option name"));
1805    }
1806
1807    #[test]
1808    fn shopt_query_specific() {
1809        let (events, status) = run_shell("shopt nullglob");
1810        assert_eq!(status, 0);
1811        let out = get_stdout(&events);
1812        assert!(out.contains("nullglob\toff"));
1813    }
1814
1815    #[test]
1816    fn shopt_sourcepath_defaults_on() {
1817        let (events, status) = run_shell("shopt sourcepath");
1818        assert_eq!(status, 0);
1819        let out = get_stdout(&events);
1820        assert!(out.contains("sourcepath\ton"));
1821    }
1822
1823    #[test]
1824    fn source_uses_path_when_sourcepath_is_on() {
1825        let mut rt = WorkerRuntime::new();
1826        rt.handle_command(HostCommand::Init {
1827            step_budget: 0,
1828            allowed_hosts: vec![],
1829        });
1830        rt.handle_command(HostCommand::Run {
1831            input: "mkdir -p /lib".into(),
1832        });
1833        rt.handle_command(HostCommand::WriteFile {
1834            path: "/lib/defs.sh".into(),
1835            data: b"X=from-path\n".to_vec(),
1836        });
1837        let events = rt.handle_command(HostCommand::Run {
1838            input: "PATH=/lib; source defs.sh; echo $X".into(),
1839        });
1840        let status = events
1841            .iter()
1842            .find_map(|e| {
1843                if let WorkerEvent::Exit(s) = e {
1844                    Some(*s)
1845                } else {
1846                    None
1847                }
1848            })
1849            .unwrap_or(-1);
1850        assert_eq!(status, 0);
1851        assert_eq!(get_stdout(&events), "from-path\n");
1852    }
1853
1854    #[test]
1855    fn sourcepath_can_disable_path_lookup_for_source() {
1856        let mut rt = WorkerRuntime::new();
1857        rt.handle_command(HostCommand::Init {
1858            step_budget: 0,
1859            allowed_hosts: vec![],
1860        });
1861        rt.handle_command(HostCommand::Run {
1862            input: "mkdir -p /lib".into(),
1863        });
1864        rt.handle_command(HostCommand::WriteFile {
1865            path: "/lib/defs.sh".into(),
1866            data: b"X=from-path\n".to_vec(),
1867        });
1868        let events = rt.handle_command(HostCommand::Run {
1869            input: "PATH=/lib; shopt -u sourcepath; source defs.sh".into(),
1870        });
1871        let status = events
1872            .iter()
1873            .find_map(|e| {
1874                if let WorkerEvent::Exit(s) = e {
1875                    Some(*s)
1876                } else {
1877                    None
1878                }
1879            })
1880            .unwrap_or(-1);
1881        assert_eq!(status, 1);
1882        assert!(get_stderr(&events).contains("source: defs.sh: not found"));
1883    }
1884
1885    // ---- Dynamic variables ----
1886
1887    #[test]
1888    fn dynamic_random() {
1889        let (events, status) = run_shell("echo $RANDOM");
1890        assert_eq!(status, 0);
1891        let out = get_stdout(&events);
1892        let val: u32 = out.trim().parse().unwrap();
1893        assert!(val < 32768);
1894    }
1895
1896    #[test]
1897    fn dynamic_random_changes() {
1898        // Two calls should produce different values
1899        let (events, _) = run_shell("echo $RANDOM; echo $RANDOM");
1900        let out = get_stdout(&events);
1901        let lines: Vec<&str> = out.lines().collect();
1902        assert_eq!(lines.len(), 2);
1903        assert_ne!(lines[0], lines[1]);
1904    }
1905
1906    #[test]
1907    fn dynamic_lineno() {
1908        let (events, status) = run_shell("echo $LINENO");
1909        assert_eq!(status, 0);
1910        let out = get_stdout(&events);
1911        // LINENO should be a number
1912        let _val: u32 = out.trim().parse().unwrap();
1913    }
1914
1915    #[test]
1916    fn dynamic_seconds() {
1917        let (events, status) = run_shell("echo $SECONDS");
1918        assert_eq!(status, 0);
1919        let out = get_stdout(&events);
1920        let val: u64 = out.trim().parse().unwrap();
1921        assert!(val < 60);
1922    }
1923
1924    #[test]
1925    fn dynamic_funcname() {
1926        let (events, status) = run_shell("myfn() { echo $FUNCNAME; }; myfn");
1927        assert_eq!(status, 0);
1928        assert_eq!(get_stdout(&events), "myfn\n");
1929    }
1930
1931    #[test]
1932    fn dynamic_pipestatus() {
1933        let (events, status) = run_shell("true | false; echo ${PIPESTATUS[0]} ${PIPESTATUS[1]}");
1934        assert_eq!(status, 0);
1935        assert_eq!(get_stdout(&events), "0 1\n");
1936    }
1937
1938    #[test]
1939    fn streaming_grep_no_match_returns_failure() {
1940        let (events, status) = run_shell("echo a | grep b");
1941        assert_eq!(status, 1);
1942        assert_eq!(get_stdout(&events), "");
1943    }
1944
1945    #[test]
1946    fn streaming_grep_updates_pipestatus() {
1947        let (events, status) = run_shell(
1948            "echo a | grep b | cat; echo ${PIPESTATUS[0]} ${PIPESTATUS[1]} ${PIPESTATUS[2]}",
1949        );
1950        assert_eq!(status, 0);
1951        assert_eq!(get_stdout(&events), "0 1 0\n");
1952    }
1953
1954    #[test]
1955    fn streaming_grep_respects_pipefail_status() {
1956        let (_, status) = run_shell("set -o pipefail; echo a | grep b | cat");
1957        assert_eq!(status, 1);
1958    }
1959
1960    #[test]
1961    fn dynamic_bash_source() {
1962        let mut rt = WorkerRuntime::new();
1963        rt.handle_command(HostCommand::Init {
1964            step_budget: 0,
1965            allowed_hosts: vec![],
1966        });
1967        rt.handle_command(HostCommand::WriteFile {
1968            path: "/test.sh".into(),
1969            data: b"echo $BASH_SOURCE".to_vec(),
1970        });
1971        let events = rt.handle_command(HostCommand::Run {
1972            input: "source /test.sh".into(),
1973        });
1974        assert_eq!(get_stdout(&events), "/test.sh\n");
1975    }
1976
1977    // ---- Alias/unalias ----
1978
1979    #[test]
1980    fn alias_basic() {
1981        let (events, status) = run_shell("alias ll='echo listing'; ll");
1982        assert_eq!(status, 0);
1983        assert_eq!(get_stdout(&events), "listing\n");
1984    }
1985
1986    #[test]
1987    fn alias_with_args() {
1988        let (events, status) = run_shell("alias greet='echo hello'; greet world");
1989        assert_eq!(status, 0);
1990        assert_eq!(get_stdout(&events), "hello world\n");
1991    }
1992
1993    #[test]
1994    fn shopt_expand_aliases_can_disable_alias_expansion() {
1995        let (events, status) = run_shell("alias ll='echo listing'; shopt -u expand_aliases; ll");
1996        assert_eq!(status, 127);
1997        assert!(get_stderr(&events).contains("command not found"));
1998    }
1999
2000    #[test]
2001    fn shopt_expand_aliases_can_reenable_alias_expansion() {
2002        let (events, status) = run_shell(
2003            "alias ll='echo listing'; shopt -u expand_aliases; shopt -s expand_aliases; ll",
2004        );
2005        assert_eq!(status, 0);
2006        assert_eq!(get_stdout(&events), "listing\n");
2007    }
2008
2009    #[test]
2010    fn alias_list_all() {
2011        let (events, status) = run_shell("alias ll='ls -la'; alias g='grep'; alias");
2012        assert_eq!(status, 0);
2013        let out = get_stdout(&events);
2014        assert!(out.contains("alias ll='ls -la'"));
2015        assert!(out.contains("alias g='grep'"));
2016    }
2017
2018    #[test]
2019    fn alias_show_specific() {
2020        let (events, status) = run_shell("alias ll='ls -la'; alias ll");
2021        assert_eq!(status, 0);
2022        assert_eq!(get_stdout(&events), "alias ll='ls -la'\n");
2023    }
2024
2025    #[test]
2026    fn unalias_removes() {
2027        let (events, status) = run_shell("alias ll='echo hi'; unalias ll; ll");
2028        assert_eq!(status, 127); // command not found
2029        let stderr = get_stderr(&events);
2030        assert!(stderr.contains("command not found"));
2031    }
2032
2033    #[test]
2034    fn unalias_all() {
2035        let (events, status) = run_shell("alias a='echo a'; alias b='echo b'; unalias -a; alias");
2036        assert_eq!(status, 0);
2037        assert_eq!(get_stdout(&events), "");
2038    }
2039
2040    // ---- Enhanced printf ----
2041
2042    #[test]
2043    fn printf_hex() {
2044        let (events, _) = run_shell("printf '%x' 255");
2045        assert_eq!(get_stdout(&events), "ff");
2046    }
2047
2048    #[test]
2049    fn printf_octal() {
2050        let (events, _) = run_shell("printf '%o' 8");
2051        assert_eq!(get_stdout(&events), "10");
2052    }
2053
2054    #[test]
2055    fn printf_float() {
2056        let (events, _) = run_shell("printf '%.2f' 3.14159");
2057        assert_eq!(get_stdout(&events), "3.14");
2058    }
2059
2060    #[test]
2061    fn printf_char() {
2062        let (events, _) = run_shell("printf '%c' A");
2063        assert_eq!(get_stdout(&events), "A");
2064    }
2065
2066    #[test]
2067    fn printf_width_right_align() {
2068        let (events, _) = run_shell("printf '%10s' hello");
2069        assert_eq!(get_stdout(&events), "     hello");
2070    }
2071
2072    #[test]
2073    fn printf_width_left_align() {
2074        let (events, _) = run_shell("printf '%-10s|' hello");
2075        assert_eq!(get_stdout(&events), "hello     |");
2076    }
2077
2078    #[test]
2079    fn printf_zero_pad() {
2080        let (events, _) = run_shell("printf '%05d' 42");
2081        assert_eq!(get_stdout(&events), "00042");
2082    }
2083
2084    #[test]
2085    fn printf_backslash_b() {
2086        let (events, _) = run_shell("printf '%b' 'hello\\nworld'");
2087        assert_eq!(get_stdout(&events), "hello\nworld");
2088    }
2089
2090    #[test]
2091    fn printf_shell_quote_q() {
2092        let (events, _) = run_shell("printf '%q' 'hello world'");
2093        let out = get_stdout(&events);
2094        // Should be quoted with $'...' or similar
2095        assert!(out.contains("hello") && out.contains("world"));
2096    }
2097
2098    #[test]
2099    fn printf_precision_string() {
2100        let (events, _) = run_shell("printf '%.3s' abcdef");
2101        assert_eq!(get_stdout(&events), "abc");
2102    }
2103
2104    // ---- Enhanced read ----
2105
2106    #[test]
2107    fn read_prompt() {
2108        let (events, _) = run_shell("echo hello | read -p 'Enter: ' VAR; echo done");
2109        let stderr = get_stderr(&events);
2110        assert!(stderr.contains("Enter: "));
2111    }
2112
2113    #[test]
2114    fn read_delimiter() {
2115        let (events, status) = run_shell("printf 'a:b:c' | read -d ':' VAR; echo $VAR");
2116        assert_eq!(status, 0);
2117        assert_eq!(get_stdout(&events), "a\n");
2118    }
2119
2120    #[test]
2121    fn read_nchars() {
2122        let (events, status) = run_shell("echo 'hello' | read -n 3 VAR; echo $VAR");
2123        assert_eq!(status, 0);
2124        assert_eq!(get_stdout(&events), "hel\n");
2125    }
2126
2127    #[test]
2128    fn read_exact_nchars() {
2129        let (events, status) = run_shell("printf 'ab\\ncd' | read -N 4 VAR; echo \"$VAR\"");
2130        assert_eq!(status, 0);
2131        // -N reads exactly 4 chars, ignoring delimiter
2132        let out = get_stdout(&events);
2133        assert!(out.starts_with("ab"));
2134    }
2135
2136    #[test]
2137    fn read_into_array() {
2138        let (events, status) =
2139            run_shell("echo 'one two three' | read -a arr; echo ${arr[0]} ${arr[1]} ${arr[2]}");
2140        assert_eq!(status, 0);
2141        assert_eq!(get_stdout(&events), "one two three\n");
2142    }
2143
2144    // ---- builtin keyword ----
2145
2146    #[test]
2147    fn builtin_keyword_invokes_builtin() {
2148        let (events, status) = run_shell("builtin echo hello");
2149        assert_eq!(status, 0);
2150        assert_eq!(get_stdout(&events), "hello\n");
2151    }
2152
2153    #[test]
2154    fn builtin_keyword_skips_function() {
2155        let (events, status) =
2156            run_shell("echo() { printf 'FUNC: %s\\n' \"$1\"; }; builtin echo direct");
2157        assert_eq!(status, 0);
2158        assert_eq!(get_stdout(&events), "direct\n");
2159    }
2160
2161    #[test]
2162    fn builtin_keyword_not_builtin_errors() {
2163        let (events, status) = run_shell("builtin nonexistent");
2164        assert_eq!(status, 1);
2165        let stderr = get_stderr(&events);
2166        assert!(stderr.contains("not a shell builtin"));
2167    }
2168
2169    #[test]
2170    fn builtin_keyword_inside_function_uses_real_builtin() {
2171        let (events, status) = run_shell(
2172            "echo() { builtin echo \"wrapped: $@\"; }\n\
2173             echo hello",
2174        );
2175        assert_eq!(status, 0);
2176        assert_eq!(get_stdout(&events), "wrapped: hello\n");
2177    }
2178
2179    // ---- source PATH search ----
2180
2181    #[test]
2182    fn source_path_search() {
2183        let mut rt = WorkerRuntime::new();
2184        rt.handle_command(HostCommand::Init {
2185            step_budget: 0,
2186            allowed_hosts: vec![],
2187        });
2188        // Create /bin directory and a script in it
2189        rt.handle_command(HostCommand::Run {
2190            input: "mkdir /bin".into(),
2191        });
2192        rt.handle_command(HostCommand::WriteFile {
2193            path: "/bin/helpers.sh".into(),
2194            data: b"LOADED=yes".to_vec(),
2195        });
2196        // Set PATH and source without slash
2197        let events = rt.handle_command(HostCommand::Run {
2198            input: "PATH=/bin; source helpers.sh; echo $LOADED".into(),
2199        });
2200        assert_eq!(get_stdout(&events), "yes\n");
2201    }
2202
2203    // ---- mapfile/readarray ----
2204
2205    #[test]
2206    fn mapfile_basic() {
2207        let (events, status) =
2208            run_shell("printf 'a\\nb\\nc\\n' | mapfile arr; echo ${arr[0]} ${arr[1]} ${arr[2]}");
2209        assert_eq!(status, 0);
2210        let out = get_stdout(&events);
2211        // Each element includes trailing newline by default
2212        assert!(out.contains('a'));
2213        assert!(out.contains('b'));
2214        assert!(out.contains('c'));
2215    }
2216
2217    #[test]
2218    fn mapfile_strip_newline() {
2219        let (events, status) = run_shell(
2220            "printf 'x\\ny\\nz\\n' | mapfile -t arr; echo \"${arr[0]}${arr[1]}${arr[2]}\"",
2221        );
2222        assert_eq!(status, 0);
2223        assert_eq!(get_stdout(&events), "xyz\n");
2224    }
2225
2226    #[test]
2227    fn mapfile_default_name() {
2228        let (events, status) = run_shell("printf 'hello\\nworld\\n' | mapfile; echo ${MAPFILE[0]}");
2229        assert_eq!(status, 0);
2230        let out = get_stdout(&events);
2231        assert!(out.contains("hello"));
2232    }
2233
2234    #[test]
2235    fn readarray_is_alias_for_mapfile() {
2236        let (events, status) =
2237            run_shell("printf 'a\\nb\\n' | readarray -t arr; echo ${arr[0]} ${arr[1]}");
2238        assert_eq!(status, 0);
2239        assert_eq!(get_stdout(&events), "a b\n");
2240    }
2241
2242    #[test]
2243    fn process_subst_out_feeds_inner_command() {
2244        let (events, status) = run_shell("printf hi > >(cat)");
2245        assert_eq!(status, 0);
2246        assert_eq!(get_stdout(&events), "hi");
2247    }
2248
2249    #[test]
2250    fn process_subst_out_runs_schedulable_inner_pipeline() {
2251        let (events, status) = run_shell("printf 'a\\nb\\n' > >(head -n 1 | cat)");
2252        assert_eq!(status, 0);
2253        assert_eq!(get_stdout(&events), "a\n");
2254    }
2255
2256    #[test]
2257    fn process_subst_out_runs_live_tail_pipeline() {
2258        let (events, status) = run_shell("printf 'a\\nb\\n' > >(tail -n 1 | cat)");
2259        assert_eq!(status, 0);
2260        assert_eq!(get_stdout(&events), "b\n");
2261    }
2262
2263    #[test]
2264    fn process_subst_out_runs_live_buffered_pipeline() {
2265        let (events, status) = run_shell("printf 'b\\na\\n' > >(sort | cat)");
2266        assert_eq!(status, 0);
2267        assert_eq!(get_stdout(&events), "a\nb\n");
2268    }
2269
2270    #[test]
2271    fn process_subst_out_isolates_shell_state() {
2272        let (events, status) =
2273            run_shell("foo=before; printf hi > >(foo=after; wc -c >/count.txt); echo $foo");
2274        assert_eq!(status, 0);
2275        assert_eq!(get_stdout(&events), "before\n");
2276    }
2277
2278    // ---- Pipe-ampersand (|&) ----
2279
2280    #[test]
2281    fn pipe_amp_captures_stderr() {
2282        let (events, status) = run_shell("echo error >&2 |& cat");
2283        assert_eq!(status, 0);
2284        assert_eq!(get_stdout(&events), "error\n");
2285    }
2286
2287    #[test]
2288    fn plain_pipeline_leaves_stderr_unpiped() {
2289        let (events, status) = run_shell("echo error >&2 | cat");
2290        assert_eq!(status, 0);
2291        assert_eq!(get_stdout(&events), "");
2292        assert_eq!(get_stderr(&events), "error\n");
2293    }
2294
2295    #[test]
2296    fn pipe_amp_captures_both_stdout_and_stderr() {
2297        let (events, status) = run_shell("{ echo out; echo err >&2; } |& cat");
2298        assert_eq!(status, 0);
2299        let stdout = get_stdout(&events);
2300        assert!(stdout.contains("out"));
2301        assert!(stdout.contains("err"));
2302    }
2303
2304    // ---- Case fall-through (;&) ----
2305
2306    #[test]
2307    fn case_fallthrough() {
2308        let (events, status) = run_shell(
2309            "X=a\ncase $X in\n  a) echo one ;&\n  b) echo two ;;\n  c) echo three ;;\nesac",
2310        );
2311        assert_eq!(status, 0);
2312        assert_eq!(get_stdout(&events), "one\ntwo\n");
2313    }
2314
2315    // ---- Case continue-testing (;;&) ----
2316
2317    #[test]
2318    fn case_continue_testing() {
2319        let (events, status) = run_shell(
2320            "X=abc\ncase $X in\n  a*) echo starts-a ;;&\n  *b*) echo contains-b ;;&\n  *c) echo ends-c ;;\nesac",
2321        );
2322        assert_eq!(status, 0);
2323        assert_eq!(get_stdout(&events), "starts-a\ncontains-b\nends-c\n");
2324    }
2325
2326    // ---- Case glob matching ----
2327
2328    #[test]
2329    fn case_glob_pattern() {
2330        let (events, status) =
2331            run_shell("case hello in\n  h*) echo matched ;;\n  *) echo nope ;;\nesac");
2332        assert_eq!(status, 0);
2333        assert_eq!(get_stdout(&events), "matched\n");
2334    }
2335
2336    // ---- Select ----
2337
2338    #[test]
2339    fn select_basic() {
2340        // Use echo pipe to provide stdin to select
2341        let (events, status) = run_shell(
2342            "echo 2 | select item in apple banana cherry; do\n  echo \"chose: $item\"\n  break\ndone",
2343        );
2344        assert_eq!(status, 0);
2345        let stdout = get_stdout(&events);
2346        assert!(stdout.contains("chose: banana"), "got: {stdout}");
2347    }
2348
2349    // ---- $"..." locale quoting ----
2350
2351    #[test]
2352    fn locale_quoting_basic() {
2353        let (events, status) = run_shell("echo $\"hello\"");
2354        assert_eq!(status, 0);
2355        assert_eq!(get_stdout(&events), "hello\n");
2356    }
2357
2358    #[test]
2359    fn locale_quoting_with_variable() {
2360        let (events, status) = run_shell("X=world; echo $\"hello $X\"");
2361        assert_eq!(status, 0);
2362        assert_eq!(get_stdout(&events), "hello world\n");
2363    }
2364
2365    // ---- nullglob ----
2366
2367    #[test]
2368    fn nullglob_empty_on_no_match() {
2369        let (events, status) = run_shell(
2370            "shopt -s nullglob\nresult=$(echo /nonexistent/*.xyz)\nif test -z \"$result\"; then\n  echo empty\nfi",
2371        );
2372        assert_eq!(status, 0);
2373        assert_eq!(get_stdout(&events), "empty\n");
2374    }
2375
2376    // ---- dotglob ----
2377
2378    #[test]
2379    fn dotglob_matches_hidden() {
2380        let mut rt = WorkerRuntime::new();
2381        rt.handle_command(HostCommand::Init {
2382            step_budget: 0,
2383            allowed_hosts: vec![],
2384        });
2385        rt.handle_command(HostCommand::Run {
2386            input: "mkdir /tmp2".into(),
2387        });
2388        rt.handle_command(HostCommand::WriteFile {
2389            path: "/tmp2/.hidden".into(),
2390            data: vec![],
2391        });
2392        rt.handle_command(HostCommand::WriteFile {
2393            path: "/tmp2/visible".into(),
2394            data: vec![],
2395        });
2396        let events = rt.handle_command(HostCommand::Run {
2397            input: "cd /tmp2; shopt -s dotglob; echo * | tr ' ' '\\n' | sort".into(),
2398        });
2399        let stdout = get_stdout(&events);
2400        assert!(stdout.contains(".hidden"), "got: {stdout}");
2401        assert!(stdout.contains("visible"), "got: {stdout}");
2402    }
2403
2404    // ---- nocasematch ----
2405
2406    #[test]
2407    fn nocasematch_case_statement() {
2408        let (events, status) = run_shell(
2409            "shopt -s nocasematch\nX=Hello\ncase $X in\n  hello) echo matched ;;\n  *) echo no-match ;;\nesac",
2410        );
2411        assert_eq!(status, 0);
2412        assert_eq!(get_stdout(&events), "matched\n");
2413    }
2414
2415    #[test]
2416    fn nocasematch_double_bracket() {
2417        let (events, status) = run_shell(
2418            "shopt -s nocasematch\nif [[ HELLO == hello ]]; then echo yes; else echo no; fi",
2419        );
2420        assert_eq!(status, 0);
2421        assert_eq!(get_stdout(&events), "yes\n");
2422    }
2423
2424    // ---- extglob matching ----
2425
2426    #[test]
2427    fn extglob_match_at_basic() {
2428        assert!(extglob_match("@(jpg|png)", "jpg"));
2429        assert!(extglob_match("@(jpg|png)", "png"));
2430        assert!(!extglob_match("@(jpg|png)", "txt"));
2431    }
2432
2433    #[test]
2434    fn extglob_match_star_suffix() {
2435        assert!(extglob_match("*.@(jpg|png)", "file.jpg"));
2436        assert!(extglob_match("*.@(jpg|png)", "file.png"));
2437        assert!(!extglob_match("*.@(jpg|png)", "file.txt"));
2438    }
2439
2440    #[test]
2441    fn extglob_match_not() {
2442        assert!(!extglob_match("!(*.log)", "b.log"));
2443        assert!(extglob_match("!(*.log)", "a.txt"));
2444    }
2445
2446    #[test]
2447    fn extglob_match_optional() {
2448        assert!(extglob_match("colo?(u)r", "color"));
2449        assert!(extglob_match("colo?(u)r", "colour"));
2450        assert!(!extglob_match("colo?(u)r", "colouur"));
2451    }
2452
2453    // ---- extglob (integration) ----
2454
2455    #[test]
2456    fn extglob_at_pattern() {
2457        let mut rt = WorkerRuntime::new();
2458        rt.handle_command(HostCommand::Init {
2459            step_budget: 0,
2460            allowed_hosts: vec![],
2461        });
2462        rt.handle_command(HostCommand::WriteFile {
2463            path: "/tmp3/file.jpg".into(),
2464            data: vec![],
2465        });
2466        rt.handle_command(HostCommand::WriteFile {
2467            path: "/tmp3/file.png".into(),
2468            data: vec![],
2469        });
2470        rt.handle_command(HostCommand::WriteFile {
2471            path: "/tmp3/file.txt".into(),
2472            data: vec![],
2473        });
2474        let events = rt.handle_command(HostCommand::Run {
2475            input: "cd /tmp3; shopt -s extglob; for f in *.@(jpg|png); do echo $f; done | sort"
2476                .into(),
2477        });
2478        let stdout = get_stdout(&events);
2479        assert!(stdout.contains("file.jpg"), "got: {stdout}");
2480        assert!(stdout.contains("file.png"), "got: {stdout}");
2481        assert!(!stdout.contains("file.txt"), "got: {stdout}");
2482    }
2483
2484    #[test]
2485    fn extglob_not_pattern() {
2486        let mut rt = WorkerRuntime::new();
2487        rt.handle_command(HostCommand::Init {
2488            step_budget: 0,
2489            allowed_hosts: vec![],
2490        });
2491        rt.handle_command(HostCommand::WriteFile {
2492            path: "/tmp4/a.txt".into(),
2493            data: vec![],
2494        });
2495        rt.handle_command(HostCommand::WriteFile {
2496            path: "/tmp4/b.log".into(),
2497            data: vec![],
2498        });
2499        rt.handle_command(HostCommand::WriteFile {
2500            path: "/tmp4/c.txt".into(),
2501            data: vec![],
2502        });
2503        let events = rt.handle_command(HostCommand::Run {
2504            input: "cd /tmp4; shopt -s extglob; for f in !(*.log); do echo $f; done | sort".into(),
2505        });
2506        let stdout = get_stdout(&events);
2507        assert!(stdout.contains("a.txt"), "got: {stdout}");
2508        assert!(stdout.contains("c.txt"), "got: {stdout}");
2509        assert!(!stdout.contains("b.log"), "got: {stdout}");
2510    }
2511
2512    #[test]
2513    fn extglob_optional_pattern() {
2514        let mut rt = WorkerRuntime::new();
2515        rt.handle_command(HostCommand::Init {
2516            step_budget: 0,
2517            allowed_hosts: vec![],
2518        });
2519        rt.handle_command(HostCommand::WriteFile {
2520            path: "/tmp5/color".into(),
2521            data: vec![],
2522        });
2523        rt.handle_command(HostCommand::WriteFile {
2524            path: "/tmp5/colour".into(),
2525            data: vec![],
2526        });
2527        let events = rt.handle_command(HostCommand::Run {
2528            input: "cd /tmp5; shopt -s extglob; for f in colo?(u)r; do echo $f; done | sort".into(),
2529        });
2530        let stdout = get_stdout(&events);
2531        assert!(stdout.contains("color"), "got: {stdout}");
2532        assert!(stdout.contains("colour"), "got: {stdout}");
2533    }
2534
2535    // ---- globstar ----
2536
2537    #[test]
2538    fn globstar_recursive() {
2539        let mut rt = WorkerRuntime::new();
2540        rt.handle_command(HostCommand::Init {
2541            step_budget: 0,
2542            allowed_hosts: vec![],
2543        });
2544        rt.handle_command(HostCommand::WriteFile {
2545            path: "/project/a.txt".into(),
2546            data: vec![],
2547        });
2548        rt.handle_command(HostCommand::WriteFile {
2549            path: "/project/sub/b.txt".into(),
2550            data: vec![],
2551        });
2552        rt.handle_command(HostCommand::WriteFile {
2553            path: "/project/sub/deep/c.txt".into(),
2554            data: vec![],
2555        });
2556        let events = rt.handle_command(HostCommand::Run {
2557            input: "cd /project; shopt -s globstar; for f in **/*.txt; do echo $f; done | sort"
2558                .into(),
2559        });
2560        let stdout = get_stdout(&events);
2561        assert!(stdout.contains("a.txt"), "got: {stdout}");
2562        assert!(stdout.contains("sub/b.txt"), "got: {stdout}");
2563        assert!(stdout.contains("sub/deep/c.txt"), "got: {stdout}");
2564    }
2565
2566    #[test]
2567    fn exec_live_redirections_preserve_left_to_right_dup_order() {
2568        let mut rt = WorkerRuntime::new();
2569        rt.handle_command(HostCommand::Init {
2570            step_budget: 0,
2571            allowed_hosts: vec![],
2572        });
2573
2574        let events = rt.handle_command(HostCommand::Run {
2575            input: "printf hi > /first.txt 1>&2\nprintf hi 1>&2 > /second.txt".into(),
2576        });
2577
2578        assert_eq!(get_stdout(&events), "");
2579        assert_eq!(get_stderr(&events), "hi");
2580
2581        let first = rt.handle_command(HostCommand::ReadFile {
2582            path: "/first.txt".into(),
2583        });
2584        assert_eq!(get_stdout(&first), "");
2585
2586        let second = rt.handle_command(HostCommand::ReadFile {
2587            path: "/second.txt".into(),
2588        });
2589        assert_eq!(get_stdout(&second), "hi");
2590    }
2591
2592    #[test]
2593    fn exec_process_subst_redirections_preserve_left_to_right_dup_order() {
2594        let mut rt = WorkerRuntime::new();
2595        rt.handle_command(HostCommand::Init {
2596            step_budget: 0,
2597            allowed_hosts: vec![],
2598        });
2599
2600        let events = rt.handle_command(HostCommand::Run {
2601            input: "printf hi > >(cat) 1>&2\nprintf hi 1>&2 > >(cat)".into(),
2602        });
2603
2604        assert_eq!(get_stdout(&events), "hi");
2605        assert_eq!(get_stderr(&events), "hi");
2606    }
2607
2608    #[test]
2609    fn process_subst_in_streams_native_pipeline() {
2610        let mut rt = WorkerRuntime::new();
2611        rt.handle_command(HostCommand::Init {
2612            step_budget: 0,
2613            allowed_hosts: vec![],
2614        });
2615
2616        let events = rt.handle_command(HostCommand::Run {
2617            input: "cat <(yes | head -n 5)".into(),
2618        });
2619
2620        assert_eq!(get_stdout(&events), "y\ny\ny\ny\ny\n");
2621        assert_eq!(get_stderr(&events), "");
2622    }
2623
2624    #[test]
2625    fn process_subst_in_streams_native_sed_pipeline() {
2626        let mut rt = WorkerRuntime::new();
2627        rt.handle_command(HostCommand::Init {
2628            step_budget: 0,
2629            allowed_hosts: vec![],
2630        });
2631
2632        let events = rt.handle_command(HostCommand::Run {
2633            input: "cat <(yes | sed 's/y/z/' | head -n 3)".into(),
2634        });
2635
2636        assert_eq!(get_stdout(&events), "z\nz\nz\n");
2637        assert_eq!(get_stderr(&events), "");
2638    }
2639
2640    #[test]
2641    fn process_subst_in_buffered_pipeline_still_works() {
2642        let mut rt = WorkerRuntime::new();
2643        rt.handle_command(HostCommand::Init {
2644            step_budget: 0,
2645            allowed_hosts: vec![],
2646        });
2647
2648        let events = rt.handle_command(HostCommand::Run {
2649            input: "cat <(printf 'b\\na\\n' | sort)".into(),
2650        });
2651
2652        assert_eq!(get_stdout(&events), "a\nb\n");
2653        assert_eq!(get_stderr(&events), "");
2654    }
2655
2656    #[test]
2657    fn process_subst_out_runs_live_tee_pipeline() {
2658        let mut rt = WorkerRuntime::new();
2659        rt.handle_command(HostCommand::Init {
2660            step_budget: 0,
2661            allowed_hosts: vec![],
2662        });
2663
2664        let events = rt.handle_command(HostCommand::Run {
2665            input: "printf 'a\\nb\\n' > >(tee /tee.txt | cat)".into(),
2666        });
2667
2668        assert_eq!(get_stdout(&events), "a\nb\n");
2669        assert_eq!(get_stderr(&events), "");
2670
2671        let file = rt.handle_command(HostCommand::ReadFile {
2672            path: "/tee.txt".into(),
2673        });
2674        assert_eq!(get_stdout(&file), "a\nb\n");
2675    }
2676
2677    #[test]
2678    fn builtin_and_utility_redirections_write_files_during_execution() {
2679        let mut rt = WorkerRuntime::new();
2680        rt.handle_command(HostCommand::Init {
2681            step_budget: 0,
2682            allowed_hosts: vec![],
2683        });
2684
2685        let events = rt.handle_command(HostCommand::Run {
2686            input: "type printf > /builtin.txt\nprintf hi > /utility.txt".into(),
2687        });
2688
2689        let status = events
2690            .iter()
2691            .find_map(|event| {
2692                if let WorkerEvent::Exit(code) = event {
2693                    Some(*code)
2694                } else {
2695                    None
2696                }
2697            })
2698            .unwrap_or(-1);
2699        assert_eq!(status, 0);
2700        assert_eq!(get_stdout(&events), "");
2701        assert_eq!(get_stderr(&events), "");
2702
2703        let builtin = rt.handle_command(HostCommand::ReadFile {
2704            path: "/builtin.txt".into(),
2705        });
2706        assert!(get_stdout(&builtin).contains("printf"));
2707
2708        let utility = rt.handle_command(HostCommand::ReadFile {
2709            path: "/utility.txt".into(),
2710        });
2711        assert_eq!(get_stdout(&utility), "hi");
2712    }
2713
2714    // ── Coverage gap tests ─────────────────────────────────────────────
2715
2716    #[test]
2717    fn pushd_popd_dirs_manage_directory_stack() {
2718        let (events, _) = run_shell("mkdir -p /a /b; cd /a; pushd /b; popd; pwd");
2719        // pushd prints "/b /a", popd prints "/a", pwd prints "/a"
2720        assert_eq!(get_stdout(&events), "/b /a\n/a\n/a\n");
2721    }
2722
2723    #[test]
2724    fn pushd_no_arg_swaps_top_two_dirs() {
2725        let (events, _) = run_shell("mkdir -p /a /b; cd /a; pushd /b; pushd; pwd");
2726        // pushd /b → cwd=/b, stack=[/a] → prints "/b /a"
2727        // pushd (no arg) → swaps to /a (top of stack), pushes /b → prints "/a /b /a"
2728        // pwd → "/a"
2729        assert_eq!(get_stdout(&events), "/b /a\n/a /b /a\n/a\n");
2730    }
2731
2732    #[test]
2733    fn popd_empty_stack_errors() {
2734        let (events, _) = run_shell("popd");
2735        assert!(get_stderr(&events).contains("directory stack empty"));
2736    }
2737
2738    #[test]
2739    fn test_unary_file_operators_extended() {
2740        let (events, _) = run_shell(
2741            "echo data > /tmp/f; [ -O /tmp/f ] && echo O_ok; [ -G /tmp/f ] && echo G_ok; [ -N /tmp/f ] && echo N_ok",
2742        );
2743        assert_eq!(get_stdout(&events), "O_ok\nG_ok\nN_ok\n");
2744    }
2745
2746    #[test]
2747    fn test_binary_ef_operator() {
2748        let (events, _) =
2749            run_shell("echo x > /tmp/same; [ /tmp/same -ef /tmp/same ] && echo ef_ok");
2750        assert_eq!(get_stdout(&events), "ef_ok\n");
2751    }
2752
2753    #[test]
2754    fn test_symlink_operators_return_false() {
2755        let (events, _) = run_shell(
2756            "echo x > /tmp/f; [ -L /tmp/f ] || echo L_ok; [ -S /tmp/f ] || echo S_ok; [ -p /tmp/f ] || echo p_ok",
2757        );
2758        assert_eq!(get_stdout(&events), "L_ok\nS_ok\np_ok\n");
2759    }
2760
2761    #[test]
2762    fn export_dash_p_lists_exported_vars() {
2763        let (events, _) = run_shell("export MY_VAR=hello; export -p | grep MY_VAR");
2764        assert_eq!(get_stdout(&events), "declare -x MY_VAR=\"hello\"\n");
2765    }
2766
2767    #[test]
2768    fn export_dash_n_unexports_variable() {
2769        let (events, _) =
2770            run_shell("export FOO=bar; export -n FOO; export -p | grep FOO; echo done");
2771        // After unexport, grep finds nothing, so only "done" shows
2772        assert_eq!(get_stdout(&events), "done\n");
2773    }
2774
2775    #[test]
2776    fn readonly_dash_p_lists_readonly_vars() {
2777        let (events, _) = run_shell("readonly CONST=42; readonly -p | grep CONST");
2778        assert_eq!(get_stdout(&events), "declare -r CONST=\"42\"\n");
2779    }
2780
2781    #[test]
2782    fn declare_dash_f_lists_function_bodies() {
2783        let (events, _) = run_shell("myfn() { echo hello; }; declare -f myfn");
2784        assert!(get_stdout(&events).contains("myfn"));
2785    }
2786
2787    #[test]
2788    fn declare_dash_cap_f_lists_function_names() {
2789        let (events, _) = run_shell("aaa() { :; }; bbb() { :; }; declare -F | sort");
2790        assert_eq!(get_stdout(&events), "declare -f aaa\ndeclare -f bbb\n");
2791    }
2792
2793    #[test]
2794    fn trap_reentry_does_not_recurse() {
2795        // DEBUG trap should not trigger recursively inside itself
2796        let (events, status) = run_shell("trap 'echo trapped' DEBUG; echo one; echo two");
2797        let stdout = get_stdout(&events);
2798        // Each command triggers DEBUG once; no infinite recursion
2799        assert!(stdout.contains("trapped"));
2800        assert!(stdout.contains("one"));
2801        assert!(stdout.contains("two"));
2802        assert_eq!(status, 0);
2803    }
2804
2805    #[test]
2806    fn signal_with_sig_prefix_is_accepted() {
2807        let mut rt = WorkerRuntime::new();
2808        rt.handle_command(HostCommand::Init {
2809            step_budget: 0,
2810            allowed_hosts: vec![],
2811        });
2812        rt.handle_command(HostCommand::Run {
2813            input: "trap 'echo caught' TERM".into(),
2814        });
2815        let events = rt.handle_command(HostCommand::Signal {
2816            signal: "SIGTERM".into(),
2817        });
2818        assert_eq!(get_stdout(&events), "caught\n");
2819    }
2820
2821    #[test]
2822    fn signal_by_number_is_accepted() {
2823        let mut rt = WorkerRuntime::new();
2824        rt.handle_command(HostCommand::Init {
2825            step_budget: 0,
2826            allowed_hosts: vec![],
2827        });
2828        rt.handle_command(HostCommand::Run {
2829            input: "trap 'echo got15' TERM".into(),
2830        });
2831        let events = rt.handle_command(HostCommand::Signal {
2832            signal: "15".into(),
2833        });
2834        assert_eq!(get_stdout(&events), "got15\n");
2835    }
2836
2837    #[test]
2838    fn signal_case_insensitive() {
2839        let mut rt = WorkerRuntime::new();
2840        rt.handle_command(HostCommand::Init {
2841            step_budget: 0,
2842            allowed_hosts: vec![],
2843        });
2844        rt.handle_command(HostCommand::Run {
2845            input: "trap 'echo ok' TERM".into(),
2846        });
2847        let events = rt.handle_command(HostCommand::Signal {
2848            signal: "term".into(),
2849        });
2850        assert_eq!(get_stdout(&events), "ok\n");
2851    }
2852
2853    #[test]
2854    fn ignored_signal_produces_no_output() {
2855        let mut rt = WorkerRuntime::new();
2856        rt.handle_command(HostCommand::Init {
2857            step_budget: 0,
2858            allowed_hosts: vec![],
2859        });
2860        rt.handle_command(HostCommand::Run {
2861            input: "trap '' TERM".into(),
2862        });
2863        let events = rt.handle_command(HostCommand::Signal {
2864            signal: "TERM".into(),
2865        });
2866        assert_eq!(get_stdout(&events), "");
2867        // Should not exit
2868        assert!(!events.iter().any(|e| matches!(e, WorkerEvent::Exit(_))));
2869    }
2870}
2871// ── wasm-bindgen entry points (wasm32 only) ────────────────────────
2872
2873#[cfg(target_arch = "wasm32")]
2874mod wasm_bindings {
2875    use wasm_bindgen::prelude::*;
2876    use wasmsh_protocol::HostCommand;
2877    use wasmsh_utils::net_types::{
2878        HostAllowlist, HttpRequest, HttpResponse, NetworkBackend, NetworkError,
2879    };
2880
2881    use crate::WorkerRuntime;
2882
2883    // JS function provided by the worker scope for synchronous HTTP.
2884    #[wasm_bindgen]
2885    extern "C" {
2886        /// Synchronous HTTP fetch implemented in JavaScript (Web Worker).
2887        /// Returns a JS object: `{ status: number, headers_json: string, body: Uint8Array }`.
2888        fn wasmsh_http_fetch(
2889            url: &str,
2890            method: &str,
2891            headers_json: &str,
2892            body: &[u8],
2893            body_len: u32,
2894            follow_redirects: bool,
2895        ) -> JsValue;
2896    }
2897
2898    /// Network backend using synchronous `XMLHttpRequest` in a Web Worker.
2899    struct BrowserNetworkBackend {
2900        allowlist: HostAllowlist,
2901    }
2902
2903    impl NetworkBackend for BrowserNetworkBackend {
2904        fn check_url(&self, url: &str) -> Result<(), NetworkError> {
2905            self.allowlist.check(url)
2906        }
2907
2908        fn fetch(&self, request: &HttpRequest) -> Result<HttpResponse, NetworkError> {
2909            self.allowlist.check(&request.url)?;
2910
2911            let headers_json =
2912                serde_json::to_string(&request.headers).unwrap_or_else(|_| "[]".into());
2913            let body = request.body.as_deref().unwrap_or(&[]);
2914            let body_len = body.len() as u32;
2915
2916            let result = wasmsh_http_fetch(
2917                &request.url,
2918                &request.method,
2919                &headers_json,
2920                body,
2921                body_len,
2922                request.follow_redirects,
2923            );
2924
2925            // Parse the JS result object.
2926            let status = js_sys::Reflect::get(&result, &"status".into())
2927                .ok()
2928                .and_then(|v| v.as_f64())
2929                .unwrap_or(0.0) as u16;
2930
2931            let headers_str = js_sys::Reflect::get(&result, &"headers_json".into())
2932                .ok()
2933                .and_then(|v| v.as_string())
2934                .unwrap_or_else(|| "[]".into());
2935            let headers: Vec<(String, String)> =
2936                serde_json::from_str(&headers_str).unwrap_or_default();
2937
2938            let body_val = js_sys::Reflect::get(&result, &"body".into())
2939                .ok()
2940                .unwrap_or(JsValue::NULL);
2941            let body_bytes = if body_val.is_instance_of::<js_sys::Uint8Array>() {
2942                js_sys::Uint8Array::from(body_val).to_vec()
2943            } else {
2944                Vec::new()
2945            };
2946
2947            // Check for error field (connection failure, etc.)
2948            if let Ok(err_val) = js_sys::Reflect::get(&result, &"error".into()) {
2949                if let Some(err_msg) = err_val.as_string() {
2950                    return Err(NetworkError::ConnectionFailed(err_msg));
2951                }
2952            }
2953
2954            Ok(HttpResponse {
2955                status,
2956                headers,
2957                body: body_bytes,
2958            })
2959        }
2960    }
2961
2962    /// Browser-facing shell instance exposed via `wasm-bindgen`.
2963    #[wasm_bindgen]
2964    #[allow(missing_debug_implementations)]
2965    pub struct WasmShell {
2966        runtime: WorkerRuntime,
2967    }
2968
2969    #[wasm_bindgen]
2970    impl WasmShell {
2971        /// Create a new shell instance.
2972        #[wasm_bindgen(constructor)]
2973        pub fn new() -> Self {
2974            console_error_panic_hook::set_once();
2975            Self {
2976                runtime: WorkerRuntime::new(),
2977            }
2978        }
2979
2980        /// Initialize the shell with a step budget and a network allowlist.
2981        /// `allowed_hosts_json` is a JSON array of host patterns (default `"[]"`).
2982        /// An empty allowlist creates a backend that denies every host, so
2983        /// callers get a `host denied` error instead of `network access not
2984        /// available`.  Returns a JSON array of events.
2985        pub fn init(&mut self, step_budget: u64, allowed_hosts_json: &str) -> String {
2986            let allowed_hosts: Vec<String> =
2987                serde_json::from_str(allowed_hosts_json).unwrap_or_default();
2988
2989            let backend = BrowserNetworkBackend {
2990                allowlist: HostAllowlist::new(allowed_hosts.clone()),
2991            };
2992            self.runtime.set_network_backend(Box::new(backend));
2993
2994            let events = self.runtime.handle_command(HostCommand::Init {
2995                step_budget,
2996                allowed_hosts,
2997            });
2998            serde_json::to_string(&events).unwrap_or_default()
2999        }
3000
3001        /// Execute a shell command.  Returns a JSON array of events.
3002        #[wasm_bindgen(js_name = "exec")]
3003        pub fn run(&mut self, input: &str) -> String {
3004            let events = self.runtime.handle_command(HostCommand::Run {
3005                input: input.to_string(),
3006            });
3007            serde_json::to_string(&events).unwrap_or_default()
3008        }
3009
3010        /// Write a file to the VFS.  Returns a JSON array of events.
3011        pub fn write_file(&mut self, path: &str, data: &[u8]) -> String {
3012            let events = self.runtime.handle_command(HostCommand::WriteFile {
3013                path: path.to_string(),
3014                data: data.to_vec(),
3015            });
3016            serde_json::to_string(&events).unwrap_or_default()
3017        }
3018
3019        /// Read a file from the VFS.  Returns a JSON array of events.
3020        pub fn read_file(&mut self, path: &str) -> String {
3021            let events = self.runtime.handle_command(HostCommand::ReadFile {
3022                path: path.to_string(),
3023            });
3024            serde_json::to_string(&events).unwrap_or_default()
3025        }
3026
3027        /// List a directory.  Returns a JSON array of events.
3028        pub fn list_dir(&mut self, path: &str) -> String {
3029            let events = self.runtime.handle_command(HostCommand::ListDir {
3030                path: path.to_string(),
3031            });
3032            serde_json::to_string(&events).unwrap_or_default()
3033        }
3034
3035        /// Cancel the currently running execution.  Returns a JSON array of events.
3036        pub fn cancel(&mut self) -> String {
3037            let events = self.runtime.handle_command(HostCommand::Cancel);
3038            serde_json::to_string(&events).unwrap_or_default()
3039        }
3040
3041        /// Deliver a POSIX signal name or number.  Returns a JSON array of events.
3042        pub fn signal(&mut self, signal: &str) -> String {
3043            let events = self.runtime.handle_command(HostCommand::Signal {
3044                signal: signal.to_string(),
3045            });
3046            serde_json::to_string(&events).unwrap_or_default()
3047        }
3048    }
3049}