1extern crate self as oxdock_core;
2
3pub mod exec;
4pub mod pipeline;
5pub use exec::*;
6pub use oxdock_parser::{
7 Arg, ArgType, CommandMeta, CommandSpec, StepKind, all_metadata, all_structural_metadata,
8 lower_command,
9};
10pub use oxdock_process::ProcessManager;
11
12define_pipeline! {
13 StepKind::Run(..) => exec::dispatch_run,
14 StepKind::RunExec { .. } => exec::dispatch_run_exec,
15 StepKind::AsyncBlock { .. } => exec::dispatch_async_block,
16 StepKind::Echo(..) => exec::dispatch_echo,
17 StepKind::Workdir(..) => exec::dispatch_workdir,
18 StepKind::Workspace(..) => exec::dispatch_workspace,
19 StepKind::Env { .. } => exec::dispatch_env,
20 StepKind::Copy { .. } => exec::dispatch_copy,
21 StepKind::CopyGit { .. } => exec::dispatch_copy_git,
22 StepKind::Symlink { .. } => exec::dispatch_symlink,
23 StepKind::Mkdir(..) => exec::dispatch_mkdir,
24 StepKind::Ls(..) => exec::dispatch_ls,
25 StepKind::Cwd => exec::dispatch_cwd,
26 StepKind::Read(..) => exec::dispatch_read,
27 StepKind::ReadLine { .. } => exec::dispatch_read_line,
28 StepKind::Write { .. } => exec::dispatch_write,
29 StepKind::Append { .. } => exec::dispatch_append,
30 StepKind::Expand { .. } => exec::dispatch_expand,
31 StepKind::AssertEq { .. } => exec::dispatch_assert_eq,
32 StepKind::AssertContains { .. } => exec::dispatch_assert_contains,
33 StepKind::HashSha256 { .. } => exec::dispatch_hash_sha256,
34 StepKind::Exit(..) => exec::dispatch_exit,
35 StepKind::AssignAsync { .. } => exec::dispatch_assign_async_step,
36 StepKind::Set { .. } => exec::dispatch_set,
37 StepKind::Await { .. } => exec::dispatch_await_step,
38 StepKind::AssignCapture { .. } => exec::dispatch_assign_capture_step,
39 StepKind::AwaitCapture { .. } => exec::dispatch_await_capture_step,
40 StepKind::Cancel { .. } => exec::dispatch_cancel_step,
41 StepKind::Timeout { .. } => exec::dispatch_timeout_step,
42 StepKind::Sleep { .. } => exec::dispatch_sleep_step,
43 StepKind::FuncDef { .. } => exec::dispatch_func_def,
44 StepKind::Call { .. } => exec::dispatch_call,
45 StepKind::Return { .. } => exec::dispatch_return,
46 StepKind::While { .. } => exec::dispatch_while_loop,
47 StepKind::Break => exec::dispatch_break,
48 StepKind::Continue => exec::dispatch_continue,
49}
50
51pub fn parse_script(input: &str) -> anyhow::Result<Vec<oxdock_parser::Step>> {
58 parse_script_with_modules(input, std_module_table())
59}
60
61pub fn parse_script_with_modules(
68 input: &str,
69 modules: oxdock_parser::ModuleTable,
70) -> anyhow::Result<Vec<oxdock_parser::Step>> {
71 let mut reserved = builtin_function_names();
72 reserved.extend(modules.reserved_base_names());
73 Ok(oxdock_parser::parse_script_with_modules(
74 input,
75 lower_command,
76 reserved,
77 modules,
78 )?)
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84 use indoc::indoc;
85 use oxdock_fs::{GuardedPath, GuardedTempDir, PathResolver};
86 use oxdock_parser::{Step, StepKind};
87 #[cfg(unix)]
88 use std::time::Instant;
89
90 fn guard_root(temp: &GuardedTempDir) -> GuardedPath {
91 temp.as_guarded_path().clone()
92 }
93
94 fn read_trimmed(path: &GuardedPath) -> String {
95 let resolver = PathResolver::new(path.root(), path.root()).unwrap();
96 resolver
97 .read_to_string(path)
98 .unwrap_or_default()
99 .trim()
100 .to_string()
101 }
102
103 fn create_dirs(path: &GuardedPath) {
104 let resolver = PathResolver::new(path.root(), path.root()).unwrap();
105 resolver.create_dir_all(path).unwrap();
106 }
107
108 fn exists(root: &GuardedPath, rel: &str) -> bool {
109 root.join(rel).map(|p| p.exists()).unwrap_or(false)
110 }
111
112 #[test]
113 fn run_isolates_cargo_target_dir_from_workspace() {
114 let temp = GuardedPath::tempdir().unwrap();
115 let root = guard_root(&temp);
116
117 #[allow(clippy::disallowed_macros)]
118 let cmd = if cfg!(windows) {
119 "echo %CARGO_TARGET_DIR% > seen.txt"
120 } else {
121 "printf %s \"$CARGO_TARGET_DIR\" > seen.txt"
122 };
123
124 let steps = vec![Step {
125 guard: None,
126 kind: StepKind::Run(cmd.to_string().into()),
127 scope_enter: 0,
128 scope_exit: 0,
129 }];
130
131 run_steps(&root, &steps).unwrap();
132
133 let seen = read_trimmed(&root.join("seen.txt").unwrap());
134 let legacy = root.join(".cargo-target").unwrap();
135
136 assert_ne!(
137 seen.trim(),
138 legacy.display().to_string(),
139 "CARGO_TARGET_DIR must not target the workspace tree"
140 );
141 assert!(
142 seen.trim().contains("oxdock-cargo-"),
143 "CARGO_TARGET_DIR must point at the isolated scratch location, got {seen:?}"
144 );
145 }
146
147 #[test]
148 fn lazy_snapshot_run_materializes_but_local_run_does_not() {
149 let temp = GuardedPath::tempdir().unwrap();
150 let root = guard_root(&temp);
151
152 let steps = parse_script("RUN echo hi").unwrap();
154 let output = run_steps_with_lazy_snapshot(&root, &steps, ExecIo::new()).unwrap();
155 assert!(
156 output.snapshot.is_materialized(),
157 "snapshot-rooted RUN must materialize the snapshot"
158 );
159
160 let steps = parse_script("WORKSPACE LOCAL\nRUN echo hi").unwrap();
162 let output = run_steps_with_lazy_snapshot(&root, &steps, ExecIo::new()).unwrap();
163 assert!(
164 !output.snapshot.is_materialized(),
165 "LOCAL-rooted RUN must never materialize the snapshot"
166 );
167 }
168
169 #[test]
170 fn lazy_empty_run_creates_nothing() {
171 let temp = GuardedPath::tempdir().unwrap();
172 let root = guard_root(&temp);
173
174 let output = run_steps_with_lazy_snapshot(&root, &[], ExecIo::new()).unwrap();
175 assert!(!output.snapshot.is_materialized());
176 assert!(output.snapshot.get().is_none());
177 }
178
179 #[test]
180 fn lazy_local_echo_assert_failure_never_materializes_snapshot() {
181 let temp = GuardedPath::tempdir().unwrap();
182 let root = guard_root(&temp);
183
184 let script = indoc!(
185 r#"
186 WORKSPACE LOCAL
187 ECHO playground pid is 41067
188 ASSERT_CONTAINS stdout "playground pid i!!"
189 "#
190 );
191 let steps = parse_script(script).unwrap();
192
193 let mut resolver = PathResolver::new_lazy(root.clone()).unwrap();
197 resolver.set_workspace_root(root.clone());
198 let snapshot = resolver.snapshot_handle();
199 let fs: Box<dyn oxdock_fs::WorkspaceFs> = Box::new(resolver);
200 let result = run_steps_with_manager(
201 fs,
202 &steps,
203 oxdock_process::default_process_manager(),
204 ExecIo::new(),
205 );
206 assert!(result.is_err(), "mismatched ASSERT_CONTAINS must fail");
207 let err = result.err().unwrap();
208 assert!(!snapshot.is_materialized());
209 assert!(snapshot.get().is_none());
210
211 let enriched = enrich_lazy_error(&snapshot, &root, err);
212 let msg = enriched.to_string();
213 assert!(
214 msg.contains("did not contain 'playground pid i!!'"),
215 "{msg}"
216 );
217 assert!(msg.contains("never materialized"), "{msg}");
218 assert!(!msg.contains("filesystem snapshot (root"), "{msg}");
219 }
220
221 #[test]
222 fn lazy_local_echo_assert_success_leaves_snapshot_pending() {
223 let temp = GuardedPath::tempdir().unwrap();
224 let root = guard_root(&temp);
225
226 let script = indoc!(
227 r#"
228 WORKSPACE LOCAL
229 ECHO playground pid is 41067
230 ASSERT_CONTAINS stdout "playground pid is 41067"
231 "#
232 );
233 let steps = parse_script(script).unwrap();
234
235 let output = run_steps_with_lazy_snapshot(&root, &steps, ExecIo::new()).unwrap();
236 assert!(!output.snapshot.is_materialized());
237 assert!(output.snapshot.get().is_none());
238 }
239
240 #[test]
241 fn lazy_default_mode_echo_assert_failure_keeps_snapshot_pending() {
242 let temp = GuardedPath::tempdir().unwrap();
243 let root = guard_root(&temp);
244
245 let script = indoc!(
249 r#"
250 ECHO playground pid is 41067
251 ASSERT_CONTAINS stdout "playground pid i!!"
252 "#
253 );
254 let steps = parse_script(script).unwrap();
255
256 let mut resolver = PathResolver::new_lazy(root.clone()).unwrap();
260 resolver.set_workspace_root(root.clone());
261 let snapshot = resolver.snapshot_handle();
262 let fs: Box<dyn oxdock_fs::WorkspaceFs> = Box::new(resolver);
263 let result = run_steps_with_manager(
264 fs,
265 &steps,
266 oxdock_process::default_process_manager(),
267 ExecIo::new(),
268 );
269 assert!(result.is_err(), "mismatched ASSERT_CONTAINS must fail");
270 let err = result.err().unwrap();
271 assert!(!snapshot.is_materialized());
272 assert!(snapshot.get().is_none());
273
274 let enriched = enrich_lazy_error(&snapshot, &root, err);
275 let msg = enriched.to_string();
276 assert!(
277 msg.contains("did not contain 'playground pid i!!'"),
278 "{msg}"
279 );
280 assert!(msg.contains("never materialized"), "{msg}");
281 assert!(!msg.contains("filesystem snapshot (root"), "{msg}");
282 }
283
284 #[test]
285 fn lazy_default_mode_write_materializes_snapshot() {
286 let temp = GuardedPath::tempdir().unwrap();
287 let root = guard_root(&temp);
288
289 let steps = parse_script("WRITE \"snap.txt\" \"snap\"").unwrap();
292
293 let output = run_steps_with_lazy_snapshot(&root, &steps, ExecIo::new()).unwrap();
294 assert!(output.snapshot.is_materialized());
295 let snapshot_root = output.snapshot.get().expect("snapshot must be published");
296 assert!(
297 exists(snapshot_root, "snap.txt"),
298 "WRITE must land inside the materialized snapshot"
299 );
300 }
301
302 #[test]
303 fn guard_skips_when_env_missing() {
304 let temp = GuardedPath::tempdir().unwrap();
305 let root = guard_root(&temp);
306
307 let guard_var = "OXDOCK_GUARD_TEST_TOKEN_UNSET";
308 let script = format!(
309 indoc!(
310 r#"
311 [env:{guard}] WRITE "skipped.txt" "hi"
312 WRITE "kept.txt" "ok"
313 "#
314 ),
315 guard = guard_var
316 );
317 let steps = crate::parse_script(&script).unwrap();
318
319 run_steps(&root, &steps).unwrap();
320
321 assert!(
322 !exists(&root, "skipped.txt"),
323 "guarded WRITE should be skipped"
324 );
325 assert!(exists(&root, "kept.txt"), "unguarded WRITE should run");
326 }
327
328 #[test]
329 fn guard_sees_env_set_by_env_step() {
330 let temp = GuardedPath::tempdir().unwrap();
331 let root = guard_root(&temp);
332
333 let script = indoc!(
334 r#"
335 ENV FOO="1"
336 [env:FOO] WRITE "hit.txt" "yes"
337 WRITE "always.txt" "ok"
338 "#
339 );
340 let steps = crate::parse_script(script).unwrap();
341
342 run_steps(&root, &steps).unwrap();
343
344 assert!(
345 exists(&root, "hit.txt"),
346 "guarded WRITE should run after ENV sets variable"
347 );
348 assert!(exists(&root, "always.txt"), "unguarded WRITE should run");
349 }
350
351 #[test]
352 fn echo_runs_and_allows_subsequent_steps() {
353 let temp = GuardedPath::tempdir().unwrap();
354 let root = guard_root(&temp);
355
356 let script = indoc!(
357 r#"
358 ECHO "Hello, world"
359 WRITE "always.txt" "ok"
360 "#
361 );
362 let steps = crate::parse_script(script).unwrap();
363
364 run_steps(&root, &steps).unwrap();
365
366 assert!(exists(&root, "always.txt"), "WRITE after ECHO should run");
367 }
368
369 #[test]
370 fn guard_on_previous_line_applies_to_next_command() {
371 let temp = GuardedPath::tempdir().unwrap();
372 let root = guard_root(&temp);
373
374 let script = indoc!(
375 r#"
376 ENV FOO="1"
377 [env:FOO]
378 WRITE "hit.txt" "yes"
379 WRITE "always.txt" "ok"
380 "#
381 );
382 let steps = crate::parse_script(script).unwrap();
383
384 run_steps(&root, &steps).unwrap();
385
386 assert!(
387 exists(&root, "hit.txt"),
388 "guarded WRITE on next line should run"
389 );
390 assert!(exists(&root, "always.txt"), "unguarded WRITE should run");
391 }
392
393 #[test]
394 fn guard_respects_platform_negation() {
395 let temp = GuardedPath::tempdir().unwrap();
396 let root = guard_root(&temp);
397
398 let script = indoc!(
399 r#"
400 [not(unix)] WRITE "platform.txt" "hi"
401 WRITE "always.txt" "ok"
402 "#
403 );
404 let steps = crate::parse_script(script).unwrap();
405
406 run_steps(&root, &steps).unwrap();
407
408 #[allow(clippy::disallowed_macros)]
409 let expect_skipped = cfg!(unix);
410 assert_eq!(
411 exists(&root, "platform.txt"),
412 !expect_skipped,
413 "platform guard should skip on unix and run elsewhere"
414 );
415 assert!(exists(&root, "always.txt"), "unguarded WRITE should run");
416 }
417
418 #[test]
419 fn guard_block_env_scope_restores_after_exit() {
420 let temp = GuardedPath::tempdir().unwrap();
421 let root = guard_root(&temp);
422
423 let script = indoc!(
424 r#"
425 ENV RUN="1"
426 [env:RUN] {
427 ENV INNER="1"
428 WRITE "scoped.txt" "hit"
429 }
430 [env:INNER] WRITE "leak.txt" "nope"
431 "#
432 );
433 let steps = crate::parse_script(script).unwrap();
434
435 run_steps(&root, &steps).unwrap();
436
437 assert!(exists(&root, "scoped.txt"), "block should run");
438 assert!(
439 !exists(&root, "leak.txt"),
440 "env set inside block must not leak outward"
441 );
442 }
443
444 #[test]
445 fn guard_block_workdir_scope_restores_after_exit() {
446 let temp = GuardedPath::tempdir().unwrap();
447 let root = guard_root(&temp);
448
449 let script = indoc!(
450 r#"
451 MKDIR "nested"
452 ENV RUN="1"
453 [env:RUN] {
454 WORKDIR "nested"
455 WRITE "inside.txt" "ok"
456 }
457 WRITE "outside.txt" "root"
458 "#
459 );
460 let steps = crate::parse_script(script).unwrap();
461
462 run_steps(&root, &steps).unwrap();
463
464 assert!(
465 exists(&root, "nested/inside.txt"),
466 "inside write should land in nested dir"
467 );
468 assert!(
469 exists(&root, "outside.txt"),
470 "workdir should reset after block exits"
471 );
472 assert!(
473 !exists(&root, "nested/outside.txt"),
474 "writes after block should not stay scoped"
475 );
476 }
477
478 #[test]
479 fn workspace_scope_restores_after_guard_block() {
480 let snapshot = GuardedPath::tempdir().unwrap();
481 let local = GuardedPath::tempdir().unwrap();
482 let snapshot_root = guard_root(&snapshot);
483 let local_root = guard_root(&local);
484
485 let script = indoc!(
486 r#"
487 ENV RUN="1"
488 [env:RUN] {
489 WORKSPACE LOCAL
490 WRITE "local_only.txt" "inside"
491 }
492 WRITE "snapshot_only.txt" "outside"
493 "#
494 );
495 let steps = crate::parse_script(script).unwrap();
496
497 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
498
499 assert!(
500 local_root.join("local_only.txt").unwrap().exists(),
501 "workspace switch inside block should affect local root"
502 );
503 assert!(
504 snapshot_root.join("snapshot_only.txt").unwrap().exists(),
505 "writes after block must target snapshot again"
506 );
507 assert!(
508 !local_root.join("snapshot_only.txt").unwrap().exists(),
509 "workspace should reset after guard block exits"
510 );
511 }
512
513 #[test]
514 fn guard_matches_profile_env() {
515 let temp = GuardedPath::tempdir().unwrap();
517 let root = guard_root(&temp);
518
519 let profile = std::env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
520 let script = format!(
521 indoc!(
522 r#"
523 ENV PROFILE={0}
524 [eq(env:PROFILE, {0})] WRITE "hit.txt" "yes"
525 [ne(env:PROFILE, {0})] WRITE "miss.txt" "no"
526 "#
527 ),
528 profile
529 );
530
531 let steps = crate::parse_script(&script).unwrap();
532 run_steps(&root, &steps).unwrap();
533
534 assert!(
535 exists(&root, "hit.txt"),
536 "PROFILE-matching guard should run"
537 );
538 assert!(
539 !exists(&root, "miss.txt"),
540 "PROFILE inequality guard should skip for current profile"
541 );
542 }
543
544 #[test]
545 fn multiple_guards_all_must_pass() {
546 let temp = GuardedPath::tempdir().unwrap();
547 let root = guard_root(&temp);
548
549 let key = "OXDOCK_MULTI_GUARD_TEST_PASS";
550
551 let script = format!(
552 indoc!(
553 r#"
554 ENV {k}=ok
555 [env:{k},eq(env:{k}, ok)] WRITE "hit.txt" "yes"
556 WRITE "always.txt" "ok"
557 "#
558 ),
559 k = key
560 );
561 let steps = crate::parse_script(&script).unwrap();
562 run_steps(&root, &steps).unwrap();
563
564 assert!(
565 exists(&root, "hit.txt"),
566 "guarded step should run when all guards pass"
567 );
568 assert!(exists(&root, "always.txt"), "unguarded step should run");
569 }
570
571 #[test]
572 fn multiple_guards_skip_when_one_fails() {
573 let temp = GuardedPath::tempdir().unwrap();
574 let root = guard_root(&temp);
575
576 let key = "OXDOCK_MULTI_GUARD_TEST_FAIL";
577
578 let script = format!(
579 indoc!(
580 r#"
581 ENV {k}=ok
582 [env:{k},ne(env:{k}, ok)] WRITE "miss.txt" "yes"
583 WRITE "always.txt" "ok"
584 "#
585 ),
586 k = key
587 );
588 let steps = crate::parse_script(&script).unwrap();
589 run_steps(&root, &steps).unwrap();
590
591 assert!(
592 !exists(&root, "miss.txt"),
593 "guarded step should skip when any guard fails"
594 );
595 assert!(exists(&root, "always.txt"), "unguarded step should run");
596 }
597
598 #[cfg(unix)]
599 #[cfg_attr(
600 miri,
601 ignore = "stdout streaming not supported for background command under miri"
602 )]
603 #[test]
604 fn async_exits_success_and_stops_pipeline() {
605 let temp = GuardedPath::tempdir().unwrap();
606 let root = guard_root(&temp);
607
608 let script = "ASYNC RUN \"sh -c 'sleep 0.05'\"";
610 let steps = crate::parse_script(script).unwrap();
611 let res = run_steps(&root, &steps);
612 assert!(res.is_ok(), "ASYNC success should allow clean exit");
613 }
614
615 #[cfg(unix)]
616 #[cfg_attr(
617 miri,
618 ignore = "stdout streaming not supported for background command under miri"
619 )]
620 #[test]
621 fn async_failure_bubbles_status() {
622 let temp = GuardedPath::tempdir().unwrap();
623 let root = guard_root(&temp);
624
625 let script = "ASYNC RUN \"sh -c 'sleep 0.05; exit 7'\"";
626 let steps = crate::parse_script(script).unwrap();
627 let err = run_steps(&root, &steps).unwrap_err();
628 let msg = err.to_string();
629 assert!(
630 msg.contains("ASYNC process exited with status") || msg.contains("exit status: 7"),
631 "should surface failing ASYNC exit code"
632 );
633 }
634
635 #[cfg(unix)]
636 #[cfg_attr(
637 miri,
638 ignore = "timing-sensitive background process test is unreliable under Miri"
639 )]
640 #[test]
641 fn async_multiple_stops_on_first_exit_and_does_not_block_steps() {
642 let temp = GuardedPath::tempdir().unwrap();
643 let root = guard_root(&temp);
644
645 let script = indoc! {
646 r#"
647 ASYNC RUN "sh -c 'sleep 0.2; echo one > one.txt'"
648 ASYNC RUN "sh -c 'sleep 0.5; echo two > two.txt'"
649 WRITE "done.txt" "ok"
650 "#
651 };
652
653 let steps = crate::parse_script(script).unwrap();
654 let start = Instant::now();
655 let res = run_steps(&root, &steps);
656 let elapsed = start.elapsed();
657
658 assert!(res.is_ok(), "ASYNC success should allow clean exit");
659 assert!(
660 exists(&root, "done.txt"),
661 "foreground step should run after spawning backgrounds"
662 );
663 assert!(
664 exists(&root, "one.txt"),
665 "first background should finish and emit output"
666 );
667 assert!(
670 exists(&root, "two.txt"),
671 "second background should finish (poll-all waits for all)"
672 );
673
674 let upper = 0.8;
675 assert!(
676 elapsed.as_secs_f32() < upper && elapsed.as_secs_f32() > 0.15,
677 "should wait for both backgrounds (~0.5s); got {elapsed:?}"
678 );
679 }
680
681 #[cfg(unix)]
682 #[cfg_attr(
683 miri,
684 ignore = "timing-sensitive background process test is unreliable under Miri"
685 )]
686 #[test]
687 fn background_killed_when_unrelated_step_fails() {
688 let temp = GuardedPath::tempdir().unwrap();
689 let root = guard_root(&temp);
690
691 let script = indoc! {
692 r#"
693 ASYNC RUN "sh -c 'sleep 1; echo late > late.txt'"
694 RUN "__oxdock_missing_command_xyz__"
695 "#
696 };
697
698 let steps = crate::parse_script(script).unwrap();
699 assert!(
700 run_steps(&root, &steps).is_err(),
701 "pipeline should fail on the missing command"
702 );
703 assert!(
707 !exists(&root, "late.txt"),
708 "abandoned background writer must be killed by Drop teardown"
709 );
710 }
711
712 #[cfg(unix)]
713 #[cfg_attr(
714 miri,
715 ignore = "stdout streaming not supported for background command under miri"
716 )]
717 #[test]
718 fn exit_terminates_backgrounds_and_returns_code() {
719 let temp = GuardedPath::tempdir().unwrap();
720 let root = guard_root(&temp);
721
722 let script = indoc! {
723 r#"
724 ASYNC RUN "sh -c 'sleep 1; echo late > late.txt'"
725 EXIT 5
726 "#
727 };
728
729 let steps = crate::parse_script(script).unwrap();
730 let err = run_steps(&root, &steps).unwrap_err();
731 let msg = err.to_string();
732 assert!(msg.contains("EXIT requested with code 5"));
733 assert!(
734 !exists(&root, "late.txt"),
735 "background process should be killed when EXIT is hit"
736 );
737 }
738
739 #[test]
740 #[cfg_attr(
741 miri,
742 ignore = "EXIT joins real background threads; timing-sensitive under Miri"
743 )]
744 fn exit_inside_nested_block_reports_code_and_kills_backgrounds() {
745 let temp = GuardedPath::tempdir().unwrap();
746 let root = guard_root(&temp);
747
748 let script = indoc! {
749 r#"
750 ASYNC {
751 SLEEP 30s
752 }
753 [bool:true] {
754 EXIT 5
755 }
756 "#
757 };
758
759 let steps = crate::parse_script(script).unwrap();
760 let start = std::time::Instant::now();
761 let err = run_steps(&root, &steps).unwrap_err();
762 assert!(
763 err.to_string().contains("EXIT requested with code 5"),
764 "nested EXIT must report its code: {err}"
765 );
766 assert!(
767 start.elapsed() < std::time::Duration::from_secs(10),
768 "nested EXIT must kill the background SLEEP promptly"
769 );
770 }
771
772 #[cfg_attr(
773 miri,
774 ignore = "stdout streaming not supported for background command under miri"
775 )]
776 #[test]
777 fn env_applies_to_run_and_background() {
778 let temp = GuardedPath::tempdir().unwrap();
779 let root = guard_root(&temp);
780
781 #[allow(clippy::disallowed_macros)]
782 let script = if cfg!(windows) {
783 indoc! {
784 r#"
785 ENV FOO="bar"
786 RUN "echo %FOO% > run.txt"
787 ASYNC RUN "echo %FOO% > bg.txt"
788 "#
789 }
790 } else {
791 indoc! {
792 r#"
793 ENV FOO="bar"
794 RUN "sh -c 'printf %s \"$FOO\" > run.txt'"
795 ASYNC RUN "sh -c 'printf %s \"$FOO\" > bg.txt'"
796 "#
797 }
798 };
799
800 let steps = crate::parse_script(script).unwrap();
801 run_steps(&root, &steps).unwrap();
802
803 assert_eq!(read_trimmed(&root.join("run.txt").unwrap()), "bar");
804 assert_eq!(read_trimmed(&root.join("bg.txt").unwrap()), "bar");
805 }
806
807 #[test]
808 fn workspace_switches_between_snapshot_and_local() {
809 let snapshot = GuardedPath::tempdir().unwrap();
810 let local = GuardedPath::tempdir().unwrap();
811 let snapshot_root = guard_root(&snapshot);
812 let local_root = guard_root(&local);
813
814 let script = indoc! {
815 r#"
816 WRITE "snap.txt" "snap"
817 WORKSPACE LOCAL
818 WRITE "local.txt" "local"
819 WORKSPACE SNAPSHOT
820 WRITE "snap2.txt" "again"
821 "#
822 };
823
824 let steps = crate::parse_script(script).unwrap();
825 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
826
827 assert!(snapshot_root.join("snap.txt").unwrap().exists());
828 assert!(snapshot_root.join("snap2.txt").unwrap().exists());
829 assert!(local_root.join("local.txt").unwrap().exists());
830 }
831
832 #[test]
833 fn workspace_root_changes_where_slash_points() {
834 let snapshot = GuardedPath::tempdir().unwrap();
835 let local = GuardedPath::tempdir().unwrap();
836 let snapshot_root = guard_root(&snapshot);
837 let local_root = guard_root(&local);
838 let local_client = local_root.join("client").unwrap();
839 create_dirs(&local_client);
840
841 let script = indoc! {
842 r#"
843 WORKSPACE LOCAL
844 WORKDIR "/"
845 WRITE "localroot.txt" "one"
846 WORKDIR "client"
847 WRITE "client.txt" "two"
848 WORKSPACE SNAPSHOT
849 WORKDIR "/"
850 WRITE "snaproot.txt" "three"
851 "#
852 };
853
854 let steps = crate::parse_script(script).unwrap();
855 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
856
857 assert!(local_root.join("localroot.txt").unwrap().exists());
858 assert!(local_client.join("client.txt").unwrap().exists());
859 assert!(snapshot_root.join("snaproot.txt").unwrap().exists());
860 }
861}