Skip to main content

qk_executor/
lib.rs

1//! Prepare finite tasks, then execute shell commands in managed process groups.
2
3mod capture;
4mod interpolate;
5mod process;
6pub mod report;
7
8use std::collections::BTreeMap;
9use std::ffi::OsString;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result, bail};
13use qk_config::Workspace;
14use qk_taskgraph::Task;
15use serde_json::Value;
16
17pub use capture::{
18    Capture, Decoration, Display, MAX_LOG_BYTES, OutputStyle, RecordedLog, Shown, read_capture,
19    replay,
20};
21pub use process::{Outcome, execute, execute_captured, shell};
22
23/// Reports a `qk:` warning to the installed sink; by default it is written to
24/// stderr in one write, so task processes sharing stderr cannot split it.
25#[macro_export]
26macro_rules! status {
27    ($($arg:tt)*) => {{
28        $crate::report::warning(&::std::format!($($arg)*));
29    }};
30}
31
32/// How a sandboxed task's commands are held to what it declares.
33#[derive(Clone, Debug)]
34pub enum Confinement {
35    /// macOS: a Seatbelt profile, through `sandbox-exec`.
36    Seatbelt(PathBuf),
37    /// Linux: a Landlock rule set's descriptor, which each command restricts
38    /// itself to before it starts. It stays open while the run lasts.
39    Landlock(i32),
40}
41
42/// An external syscall recorder wrapping each command, with separate trace files.
43#[derive(Clone, Debug)]
44pub struct Recording {
45    pub program: PathBuf,
46    pub directory: PathBuf,
47}
48
49impl Recording {
50    /// The same invocation is used by preflight and actual task commands.
51    pub fn command(&self, index: usize, text: &str) -> std::process::Command {
52        let mut command = std::process::Command::new(&self.program);
53        // No read/write buffers or exec argument/environment arrays.
54        command
55            .args([
56                "-ff",
57                "-ttt",
58                "-yy",
59                "-qq",
60                "-s",
61                "4096",
62                "-e",
63                "trace=%file,%process,getdents64,fchdir,close,dup,dup2,dup3,getcwd",
64                "-e",
65                "raw=execve,execveat",
66                "-o",
67            ])
68            .arg(self.directory.join(format!("command-{index}")))
69            .args(["--", "/bin/sh", "-c", text]);
70        command
71    }
72}
73
74#[derive(Clone, Debug)]
75pub struct PreparedTask {
76    pub id: String,
77    pub commands: Vec<String>,
78    pub parallel: bool,
79    pub cwd: PathBuf,
80    pub env: BTreeMap<OsString, OsString>,
81    /// Variables set for the process after `env` that are not part of what
82    /// the task is: nothing reads them to key it, such as a thread count.
83    pub execution: BTreeMap<OsString, OsString>,
84    /// Nx's `readyWhen`: text whose appearance in the output, every piece of
85    /// it, makes the task ready while its commands keep running.
86    pub ready_when: Vec<String>,
87    /// Set once all of `ready_when` has appeared.
88    pub ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
89    /// Each command's prefix and colours, by position; empty without any.
90    pub decorations: Vec<Decoration>,
91    /// Whether the task reads qk's stdin, and has the terminal while it runs.
92    /// The runner sets it for the one task a run is for, when that task has a
93    /// single command whose output goes straight to the terminal.
94    pub interactive: bool,
95    /// What the commands run under, for `--sandbox`.
96    pub sandbox: Option<Confinement>,
97    pub recording: Option<Recording>,
98    /// How the task's output is shown; the runner sets it per output style.
99    pub display: Display,
100}
101
102/// The dotenv files Nx loads for a task, most specific first: in the
103/// project root, then the workspace root, for the target and configuration
104/// (`.env.<target>.<configuration>`, `.env.<configuration>`, `.env.<target>`),
105/// then `.env.local`, `.local.env` and `.env`, each also in its `.local` and
106/// `.<name>.env` spellings.
107fn dotenv_files(project_root: &str, target: &str, configuration: Option<&str>) -> Vec<String> {
108    let mut identifiers = Vec::new();
109    if let Some(configuration) = configuration {
110        identifiers.push(format!("{target}.{configuration}"));
111        identifiers.push(configuration.to_owned());
112    }
113    identifiers.push(target.to_owned());
114    identifiers.push(String::new());
115    let variants = |identifier: &str, root: &str| -> Vec<String> {
116        let prefix = if root.is_empty() || root == "." {
117            String::new()
118        } else {
119            format!("{root}/")
120        };
121        if identifier.is_empty() {
122            [".env.local", ".local.env", ".env"]
123                .map(|name| format!("{prefix}{name}"))
124                .to_vec()
125        } else {
126            [
127                format!(".env.{identifier}.local"),
128                format!(".env.{identifier}"),
129                format!(".{identifier}.local.env"),
130                format!(".{identifier}.env"),
131            ]
132            .map(|name| format!("{prefix}{name}"))
133            .to_vec()
134        }
135    };
136    let mut files = Vec::new();
137    for root in [project_root, ""] {
138        for identifier in &identifiers {
139            // Colons in target names cannot name regular dotenv files on Windows.
140            #[cfg(windows)]
141            if identifier.contains(':') {
142                continue;
143            }
144            for file in variants(identifier, root) {
145                if !files.contains(&file) {
146                    files.push(file);
147                }
148            }
149        }
150    }
151    files
152}
153
154/// A task's environment as Nx builds it: the process environment, then each
155/// of the task's dotenv files without overriding what is already set, then
156/// the variables Nx sets for every task. A target's `env` applies on top.
157fn task_environment(
158    workspace: &Workspace,
159    task: &Task,
160    base_env: &BTreeMap<OsString, OsString>,
161) -> Result<BTreeMap<OsString, OsString>> {
162    let mut env = base_env.clone();
163    let root = &workspace.projects[&task.project].root;
164    let files = if loads_dotenv(base_env) {
165        dotenv_files(root, &task.target, task.configuration.as_deref())
166    } else {
167        Vec::new()
168    };
169    for file in files {
170        for (name, value) in read_dotenv(&workspace.root.join(file))? {
171            env.entry(name).or_insert(value);
172        }
173    }
174    let mut set = |name: &str, value: OsString| {
175        env.insert(name.into(), value);
176    };
177    set("NX_WORKSPACE_ROOT", workspace.root.clone().into_os_string());
178    set("NX_TASK_TARGET_PROJECT", task.project.clone().into());
179    set("NX_TASK_TARGET_TARGET", task.target.clone().into());
180    set("LERNA_PACKAGE_NAME", task.project.clone().into());
181    set("NX_TUI", "false".into());
182    match &task.configuration {
183        Some(configuration) => set("NX_TASK_TARGET_CONFIGURATION", configuration.clone().into()),
184        None => {
185            env.remove(std::ffi::OsStr::new("NX_TASK_TARGET_CONFIGURATION"));
186        }
187    }
188    Ok(env)
189}
190
191/// Whether dotenv files load: as in Nx, `NX_LOAD_DOT_ENV_FILES=false` turns
192/// them all off.
193fn loads_dotenv(env: &BTreeMap<OsString, OsString>) -> bool {
194    env.get(std::ffi::OsStr::new("NX_LOAD_DOT_ENV_FILES"))
195        .is_none_or(|value| value != "false")
196}
197
198/// A dotenv file's entries, or none when it does not exist.
199fn read_dotenv(path: &Path) -> Result<Vec<(OsString, OsString)>> {
200    let file = match std::fs::File::open(path) {
201        Ok(file) => file,
202        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
203        Err(error) => return Err(error).with_context(|| format!("cannot read {}", path.display())),
204    };
205    dotenvy::from_read_iter(file)
206        .enumerate()
207        .map(|(index, entry)| {
208            // Dotenv parser errors can contain secret values; report only location.
209            let (name, value) = entry.map_err(|_| {
210                anyhow::anyhow!("invalid dotenv entry {} in {}", index + 1, path.display())
211            })?;
212            Ok((name.into(), value.into()))
213        })
214        .collect()
215}
216
217/// Read dotenv files into a child environment without changing process globals.
218/// Existing process variables win over .env.local, which wins over .env.
219pub fn environment(workspace: &Path) -> Result<BTreeMap<OsString, OsString>> {
220    let mut values = BTreeMap::new();
221    let process: BTreeMap<OsString, OsString> = std::env::vars_os().collect();
222    let names: &[&str] = if loads_dotenv(&process) {
223        &[".env", ".env.local"]
224    } else {
225        &[]
226    };
227    for name in names {
228        let path = workspace.join(name);
229        let file = match std::fs::File::open(&path) {
230            Ok(file) => file,
231            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
232            Err(error) => {
233                return Err(error).with_context(|| format!("cannot read {}", path.display()));
234            }
235        };
236        for (index, entry) in dotenvy::from_read_iter(file).enumerate() {
237            // Dotenv parser errors can contain secret values; report only location.
238            let (name, value) = entry.map_err(|_| {
239                anyhow::anyhow!("invalid dotenv entry {} in {}", index + 1, path.display())
240            })?;
241            values.insert(name.into(), value.into());
242        }
243    }
244    values.extend(std::env::vars_os());
245    Ok(values)
246}
247
248pub fn prepare(
249    workspace: &Workspace,
250    task: &Task,
251    base_env: &BTreeMap<OsString, OsString>,
252) -> Result<PreparedTask> {
253    let definition = &task.definition;
254    let executor = definition
255        .executor
256        .as_deref()
257        .context("target has no executor or command")?;
258    let (options, args) = if executor == "nx:run-commands" {
259        run_commands_overrides(&definition.options, &task.args)
260    } else {
261        (definition.options.clone(), task.args.clone())
262    };
263    let options = &options;
264    // Options Nx's run-commands knows; any other scalar option is forwarded to
265    // the command as `--name=value`, and object values are ignored, as in Nx.
266    const RUN_COMMANDS: &[&str] = &[
267        "command",
268        "commands",
269        "color",
270        "no-color",
271        "parallel",
272        "no-parallel",
273        "readyWhen",
274        "cwd",
275        "args",
276        "envFile",
277        "__unparsed__",
278        "env",
279        "usePty",
280        "streamOutput",
281        "verbose",
282        "forwardAllArgs",
283        "tty",
284    ];
285    let mut forwarded = BTreeMap::new();
286    if executor == "nx:run-commands" {
287        for (key, value) in options {
288            if RUN_COMMANDS.contains(&key.as_str()) {
289                continue;
290            }
291            let value = match value {
292                Value::String(text) => text.clone(),
293                Value::Number(number) => number.to_string(),
294                Value::Bool(flag) => flag.to_string(),
295                _ => continue,
296            };
297            forwarded.insert(key.clone(), value);
298        }
299    }
300    let extra = match options.get("args") {
301        None | Some(Value::Null) => None,
302        Some(Value::String(text)) => Some(text.clone()),
303        Some(Value::Array(words)) => Some(
304            words
305                .iter()
306                .map(|word| word.as_str().context("args entries must be strings"))
307                .collect::<Result<Vec<_>>>()?
308                .join(" "),
309        ),
310        Some(_) => bail!("args must be a string or an array of strings"),
311    };
312    let interpolation =
313        interpolate::Interpolation::new(workspace, task, args, &forwarded, extra.as_deref())?;
314    let mut env = task_environment(workspace, task, base_env)?;
315    // As in Nx, `envFile` sets what is not set yet, beneath `env`.
316    if let Some(file) = options.get("envFile") {
317        let file = interpolation.text(file.as_str().context("envFile must be a string")?)?;
318        let path = workspace.root.join(&file);
319        if loads_dotenv(base_env) {
320            if !path.is_file() {
321                bail!("envFile {file} does not exist");
322            }
323            for (name, value) in read_dotenv(&path)? {
324                env.entry(name).or_insert(value);
325            }
326        }
327    }
328    for values in [definition.extra.get("env"), options.get("env")]
329        .into_iter()
330        .flatten()
331    {
332        for (name, value) in values.as_object().context("env must be an object")? {
333            if name.is_empty() || name.contains(['=', '\0']) {
334                bail!("invalid environment variable name");
335            }
336            let value = value
337                .as_str()
338                .context("environment values must be strings")?;
339            env.insert(name.into(), interpolation.text(value)?.into());
340        }
341    }
342    let mut execution = BTreeMap::new();
343    execution.insert(
344        "FORCE_COLOR".into(),
345        env.get(std::ffi::OsStr::new("FORCE_COLOR"))
346            .cloned()
347            .unwrap_or_else(|| "true".into()),
348    );
349    if executor == "nx:run-commands" && boolean(options.get("color"), false, "color")? {
350        execution.insert("FORCE_COLOR".into(), "true".into());
351    }
352    for key in options.keys() {
353        let allowed = match executor {
354            // Every run-commands option is supported. `tty`, `usePty`,
355            // `streamOutput` and `verbose` have no effect: qk never gives
356            // commands a terminal, and Nx sets the others itself.
357            "nx:run-commands" => true,
358            "nx:run-script" => matches!(key.as_str(), "script" | "env" | "cwd"),
359            "nx:noop" => true,
360            _ => bail!("unsupported executor {executor:?}"),
361        };
362        if !allowed {
363            bail!("unsupported {executor} option {key:?}");
364        }
365    }
366    let default_cwd = if executor == "nx:run-script" {
367        &workspace.projects[&task.project].root
368    } else {
369        "."
370    };
371    let cwd = options
372        .get("cwd")
373        .map(|value| value.as_str().context("cwd must be a string"))
374        .transpose()?
375        .unwrap_or(default_cwd);
376    let cwd = workspace.root.join(interpolation.text(cwd)?);
377    if !cwd.is_dir() {
378        bail!("working directory does not exist: {}", cwd.display());
379    }
380    // Expose local binaries even for plain run-commands targets.
381    let mut paths = Vec::new();
382    for directory in cwd
383        .ancestors()
384        .take_while(|directory| directory.starts_with(&workspace.root))
385    {
386        paths.push(directory.join("node_modules/.bin"));
387    }
388    let path_key = if cfg!(windows) {
389        env.keys()
390            .find(|key| key.to_string_lossy().eq_ignore_ascii_case("PATH"))
391            .cloned()
392            .unwrap_or_else(|| "PATH".into())
393    } else {
394        "PATH".into()
395    };
396    if let Some(path) = env.get(&path_key) {
397        paths.extend(std::env::split_paths(path));
398    }
399    env.insert(
400        path_key,
401        std::env::join_paths(paths).context("cannot construct task PATH")?,
402    );
403    let parallel = boolean(options.get("parallel"), true, "parallel")?;
404    let mut decorations = Vec::new();
405    let ready_when: Vec<String> = match options.get("readyWhen") {
406        None | Some(Value::Null) => Vec::new(),
407        Some(Value::String(text)) => vec![text.clone()],
408        Some(value) => serde_json::from_value(value.clone())
409            .context("readyWhen must be a string or an array of strings")?,
410    };
411    if !ready_when.is_empty() && options.contains_key("commands") && !parallel {
412        bail!("readyWhen can only be used when parallel is true");
413    }
414    let mut commands = Vec::new();
415    match executor {
416        "nx:noop" => {}
417        "nx:run-script" => {
418            let script = options
419                .get("script")
420                .and_then(Value::as_str)
421                .context("run-script requires options.script")?;
422            let package = workspace
423                .packages
424                .get(&task.project)
425                .context("run-script requires a package.json")?;
426            if !package.scripts.contains_key(script) {
427                bail!("package.json has no script {script:?}");
428            }
429            let manager = package_manager(workspace)?;
430            let args = interpolation.arguments()?;
431            let script = interpolate::quote(script)?;
432            // Nx's `run` for each: npm and bun need `--` before arguments,
433            // and yarn runs a script by name.
434            let mut command = match manager {
435                "yarn" => format!("yarn {script}"),
436                manager => format!("{manager} run {script}"),
437            };
438            if !args.is_empty() {
439                if matches!(manager, "npm" | "bun") {
440                    command.push_str(" --");
441                }
442                command.push(' ');
443                command.push_str(&args);
444            }
445            commands.push(command);
446        }
447        "nx:run-commands" => {
448            if options.contains_key("command") && options.contains_key("commands") {
449                bail!("specify command or commands, not both");
450            }
451            let forward = boolean(options.get("forwardAllArgs"), true, "forwardAllArgs")?;
452            if let Some(command) = options.get("command") {
453                // Nx also takes an array, joined with spaces.
454                let command = match command {
455                    Value::String(command) => command.clone(),
456                    Value::Array(words) => words
457                        .iter()
458                        .map(|word| word.as_str().context("command words must be strings"))
459                        .collect::<Result<Vec<_>>>()?
460                        .join(" "),
461                    _ => bail!("command must be a string or an array of strings"),
462                };
463                commands.push(interpolation.command(&command, forward)?);
464            } else if let Some(values) = options.get("commands") {
465                for value in values.as_array().context("commands must be an array")? {
466                    let (command, forward) = if let Some(command) = value.as_str() {
467                        (command, forward)
468                    } else {
469                        let value = value
470                            .as_object()
471                            .context("commands entries must be strings or objects")?;
472                        for key in value.keys() {
473                            if !matches!(
474                                key.as_str(),
475                                "command"
476                                    | "forwardAllArgs"
477                                    | "prefix"
478                                    | "prefixColor"
479                                    | "color"
480                                    | "bgColor"
481                                    | "description"
482                            ) {
483                                bail!("unsupported command entry field {key:?}");
484                            }
485                        }
486                        let field =
487                            |name: &str, background: Option<bool>| -> Result<Option<String>> {
488                                let Some(value) = value.get(name) else {
489                                    return Ok(None);
490                                };
491                                let text = value.as_str().with_context(|| {
492                                    format!("command entry {name} must be a string")
493                                })?;
494                                if let Some(background) = background
495                                    && !Decoration::valid(text, background)
496                                {
497                                    bail!("command entry {name} {text:?} is not a colour");
498                                }
499                                Ok(Some(text.to_owned()))
500                            };
501                        let decoration = Decoration {
502                            prefix: field("prefix", None)?,
503                            prefix_color: field("prefixColor", Some(false))?,
504                            color: field("color", Some(false))?,
505                            bg_color: field("bgColor", Some(true))?,
506                        };
507                        if !decoration.is_empty() && !parallel {
508                            bail!(
509                                "prefix, prefixColor, color and bgColor can only be set when parallel is true"
510                            );
511                        }
512                        decorations.resize(commands.len(), Decoration::default());
513                        decorations.push(decoration);
514                        (
515                            value
516                                .get("command")
517                                .and_then(Value::as_str)
518                                .context("command entry requires a command string")?,
519                            boolean(value.get("forwardAllArgs"), forward, "forwardAllArgs")?,
520                        )
521                    };
522                    commands.push(interpolation.command(command, forward)?);
523                }
524            } else {
525                bail!("run-commands requires command or commands");
526            }
527            // An empty `commands` succeeds without running anything, as in Nx.
528            if commands.iter().any(|command| command.trim().is_empty()) {
529                bail!("run-commands requires nonempty commands");
530            }
531        }
532        _ => bail!("unsupported executor {executor:?}"),
533    }
534    Ok(PreparedTask {
535        id: task.id.clone(),
536        commands,
537        parallel,
538        cwd,
539        env,
540        execution,
541        ready_when,
542        ready: Default::default(),
543        decorations,
544        interactive: false,
545        sandbox: None,
546        recording: None,
547        display: Display::default(),
548    })
549}
550
551/// The run-commands options Nx reads, which a task's arguments may set too.
552const OPTION_FLAGS: &[&str] = &[
553    "command",
554    "commands",
555    "color",
556    "parallel",
557    "readyWhen",
558    "cwd",
559    "args",
560    "envFile",
561    "__unparsed__",
562    "env",
563    "usePty",
564    "streamOutput",
565    "verbose",
566    "forwardAllArgs",
567    "tty",
568];
569
570/// The target's options with the task's arguments that name a run-commands
571/// option applied, and the arguments left for the command. As in Nx,
572/// `--cwd=dir`, `--no-parallel` or `--env.NAME=value` set the option instead
573/// of reaching the command.
574fn run_commands_overrides(
575    options: &serde_json::Map<String, Value>,
576    args: &[String],
577) -> (serde_json::Map<String, Value>, Vec<String>) {
578    const BOOLEAN: &[&str] = &[
579        "color",
580        "parallel",
581        "usePty",
582        "streamOutput",
583        "verbose",
584        "forwardAllArgs",
585        "tty",
586    ];
587    let mut options = options.clone();
588    let mut rest = Vec::new();
589    let mut ready = Vec::new();
590    let mut args = args.iter().peekable();
591    while let Some(arg) = args.next() {
592        let Some(flag) = arg.strip_prefix("--") else {
593            rest.push(arg.clone());
594            continue;
595        };
596        let (key, value) = match flag.split_once('=') {
597            Some((key, value)) => (key, Some(value.to_owned())),
598            None => (flag, None),
599        };
600        let (key, negated) = match key.strip_prefix("no-") {
601            Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
602            _ => (key, false),
603        };
604        let (base, field) = match key.split_once('.') {
605            Some((base, field)) => (base, Some(field)),
606            None => (key, None),
607        };
608        if !OPTION_FLAGS.contains(&base) {
609            rest.push(arg.clone());
610            continue;
611        }
612        let boolean = BOOLEAN.contains(&base);
613        let value = match value {
614            Some(value) => value,
615            None if negated => "false".into(),
616            None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
617                args.next().unwrap().clone()
618            }
619            None => "true".into(),
620        };
621        let value = if boolean {
622            Value::Bool(value != "false")
623        } else {
624            Value::String(value)
625        };
626        match (base, field) {
627            ("env", Some(name)) => {
628                let env = options
629                    .entry("env")
630                    .or_insert_with(|| Value::Object(Default::default()));
631                if let Value::Object(env) = env {
632                    env.insert(name.to_owned(), value);
633                }
634            }
635            ("readyWhen", None) => ready.push(value),
636            // Nx sets these itself, and a list cannot come from a flag.
637            ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
638            (base, None) => {
639                options.insert(base.to_owned(), value);
640            }
641        }
642    }
643    if !ready.is_empty() {
644        let ready = if ready.len() == 1 {
645            ready.pop().unwrap()
646        } else {
647            Value::Array(ready)
648        };
649        options.insert("readyWhen".into(), ready);
650    }
651    (options, rest)
652}
653
654fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
655    value
656        .map(|value| {
657            value
658                .as_bool()
659                .with_context(|| format!("{name} must be boolean"))
660        })
661        .transpose()
662        .map(|value| value.unwrap_or(default))
663}
664
665/// The package manager, as Nx detects it: nx.json `cli.packageManager`,
666/// then the lockfile, then the package manager qk was invoked through. qk
667/// also reads the root `packageManager` before that last step.
668fn package_manager(workspace: &Workspace) -> Result<&'static str> {
669    let known = |name: &str| -> Result<&'static str> {
670        Ok(match name {
671            "npm" => "npm",
672            "pnpm" => "pnpm",
673            "yarn" => "yarn",
674            "bun" => "bun",
675            other => bail!("unknown package manager {other:?}"),
676        })
677    };
678    if let Some(name) = workspace
679        .config
680        .extra
681        .get("cli")
682        .and_then(|cli| cli.get("packageManager"))
683        .and_then(Value::as_str)
684    {
685        return known(name);
686    }
687    let root = &workspace.root;
688    for (file, manager) in [
689        ("bun.lockb", "bun"),
690        ("bun.lock", "bun"),
691        ("yarn.lock", "yarn"),
692        ("pnpm-lock.yaml", "pnpm"),
693        ("package-lock.json", "npm"),
694    ] {
695        if root.join(file).is_file() {
696            return Ok(manager);
697        }
698    }
699    if let Some(declared) = &workspace.package_manager {
700        return known(declared.split('@').next().unwrap_or(declared));
701    }
702    if root.join("pnpm-workspace.yaml").is_file() {
703        return Ok("pnpm");
704    }
705    let agent = std::env::var("npm_config_user_agent").unwrap_or_default();
706    for manager in ["pnpm", "yarn", "bun"] {
707        if agent.starts_with(&format!("{manager}/")) {
708            return Ok(manager);
709        }
710    }
711    Ok("npm")
712}
713
714#[cfg(all(test, windows))]
715mod dotenv_tests {
716    #[test]
717    fn colon_targets_keep_configuration_and_generic_dotenv_files() {
718        let files = super::dotenv_files("app", "test:unit:watch", Some("ci"));
719        assert!(files.iter().all(|file| !file.contains(':')));
720        assert!(files.contains(&"app/.env.ci".to_owned()));
721        assert!(files.contains(&"app/.env".to_owned()));
722        assert!(files.contains(&".env.ci".to_owned()));
723        assert!(files.contains(&".env".to_owned()));
724    }
725}