1use std::path::{Path, PathBuf};
11use std::process::Command;
12
13use clap::{Args as ClapArgs, Subcommand, ValueEnum};
14use serde::Serialize;
15
16use crate::cli::output;
17use crate::shared::error::{Error, ErrorCode};
18
19const DOCKERFILE: &str = include_str!("../../../container/docker/Dockerfile");
24const INSTALL_BACKENDS: &str = include_str!("../../../container/docker/install-backends.sh");
25const ENTRYPOINT: &str = include_str!("../../../container/docker/entrypoint.sh");
26
27const VERSION: &str = env!("CARGO_PKG_VERSION");
29const DEFAULT_NAME: &str = "afhttp-host";
31const IMAGE_REPO: &str = "afhttp-host";
32
33#[derive(ClapArgs, Debug)]
34pub struct Args {
35 #[command(subcommand)]
36 pub sub: ContainerSub,
37}
38
39#[derive(Subcommand, Debug)]
40pub enum ContainerSub {
41 Install(InstallArgs),
43 Uninstall(UninstallArgs),
45 Status(StatusArgs),
47 Logs(LogsArgs),
49}
50
51#[derive(ClapArgs, Debug)]
53pub struct CommonArgs {
54 #[arg(long, value_enum)]
56 pub runtime: Option<RuntimeArg>,
57 #[arg(long, default_value = DEFAULT_NAME)]
59 pub name: String,
60}
61
62#[derive(ClapArgs, Debug)]
63pub struct InstallArgs {
64 #[command(flatten)]
65 pub common: CommonArgs,
66 #[arg(long, default_value_t = 9222)]
68 pub port: u16,
69 #[arg(long, default_value = "work")]
71 pub profile: String,
72 #[arg(long = "shm-size", default_value = "1g")]
74 pub shm_size: String,
75 #[arg(long = "with", value_name = "BACKEND")]
78 pub with: Vec<String>,
79 #[arg(long)]
81 pub rebuild: bool,
82 #[arg(long = "from-source")]
85 pub from_source: bool,
86 #[arg(long, value_name = "DIR")]
88 pub context: Option<String>,
89 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
91 pub host_args: Vec<String>,
92}
93
94#[derive(ClapArgs, Debug)]
95pub struct UninstallArgs {
96 #[command(flatten)]
97 pub common: CommonArgs,
98 #[arg(long)]
100 pub purge: bool,
101}
102
103#[derive(ClapArgs, Debug)]
104pub struct StatusArgs {
105 #[command(flatten)]
106 pub common: CommonArgs,
107 #[arg(long, default_value_t = 9222)]
109 pub port: u16,
110}
111
112#[derive(ClapArgs, Debug)]
113pub struct LogsArgs {
114 #[command(flatten)]
115 pub common: CommonArgs,
116 #[arg(long, short = 'f')]
118 pub follow: bool,
119}
120
121#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
123pub enum RuntimeArg {
124 Docker,
125 Podman,
126 Apple,
127}
128
129#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130enum Runtime {
131 Docker,
132 Podman,
133 Apple,
134}
135
136impl From<RuntimeArg> for Runtime {
137 fn from(arg: RuntimeArg) -> Self {
138 match arg {
139 RuntimeArg::Docker => Runtime::Docker,
140 RuntimeArg::Podman => Runtime::Podman,
141 RuntimeArg::Apple => Runtime::Apple,
142 }
143 }
144}
145
146impl Runtime {
147 fn bin(self) -> &'static str {
149 match self {
150 Runtime::Docker => "docker",
151 Runtime::Podman => "podman",
152 Runtime::Apple => "container",
153 }
154 }
155
156 fn label(self) -> &'static str {
158 match self {
159 Runtime::Docker => "docker",
160 Runtime::Podman => "podman",
161 Runtime::Apple => "apple",
162 }
163 }
164}
165
166pub async fn run(args: Args) -> Result<(), Error> {
167 match args.sub {
168 ContainerSub::Install(a) => install(a).await,
169 ContainerSub::Uninstall(a) => uninstall(a),
170 ContainerSub::Status(a) => status(a).await,
171 ContainerSub::Logs(a) => logs(a),
172 }
173}
174
175#[derive(Serialize)]
178struct InstallResult {
179 runtime: &'static str,
180 image: String,
181 container: String,
182 endpoint: String,
183 profile: String,
184 token: String,
185 client_command: String,
186 backends: Vec<String>,
187}
188
189async fn install(args: InstallArgs) -> Result<(), Error> {
190 let runtime = resolve_runtime(args.common.runtime)?;
191 let backends = resolve_backends(&args.with)?;
192 let image = image_tag();
193
194 start_daemon(runtime);
195
196 if args.from_source {
199 let ctx = resolve_source_context(args.context.as_deref())?;
200 let build = build_args(
201 &image,
202 runtime,
203 BuildSource::FromSource { ctx: &ctx },
204 &backends,
205 );
206 exec_inherit(runtime.bin(), &build)?;
207 } else if args.rebuild || !image_exists(runtime, &image) {
208 let ctx = write_build_context()?;
209 let target = target_triple(runtime, std::env::consts::ARCH);
210 let build = build_args(
211 &image,
212 runtime,
213 BuildSource::Embedded { ctx: &ctx, target },
214 &backends,
215 );
216 exec_inherit(runtime.bin(), &build).map_err(|_| build_failed_error(target))?;
217 }
218
219 let _ = capture(runtime.bin(), &["stop".into(), args.common.name.clone()]);
222 let _ = capture(runtime.bin(), &["rm".into(), args.common.name.clone()]);
223
224 let run = run_args(
225 &args.common.name,
226 &image,
227 args.port,
228 &args.profile,
229 &args.shm_size,
230 &args.host_args,
231 );
232 exec_inherit(runtime.bin(), &run)?;
233
234 let token = read_token(runtime, &args.common.name).await?;
235 let endpoint = endpoint_url(args.port);
236 output::emit(
237 "container_install",
238 &InstallResult {
239 runtime: runtime.label(),
240 image,
241 container: args.common.name.clone(),
242 endpoint,
243 profile: args.profile.clone(),
244 client_command: client_command(args.port, &token),
245 token,
246 backends: backends.iter().map(|b| b.name.to_string()).collect(),
247 },
248 )
249}
250
251#[derive(Serialize)]
254struct UninstallResult {
255 runtime: &'static str,
256 container: String,
257 removed: bool,
258 image_removed: bool,
259 purged: bool,
260}
261
262fn uninstall(args: UninstallArgs) -> Result<(), Error> {
263 let runtime = resolve_runtime(args.common.runtime)?;
264 let _ = capture(runtime.bin(), &["stop".into(), args.common.name.clone()]);
265 let removed = capture(runtime.bin(), &["rm".into(), args.common.name.clone()])
266 .map(|o| o.status.success())
267 .unwrap_or(false);
268
269 let mut image_removed = false;
270 if args.purge {
271 let image = image_tag();
272 image_removed = capture(runtime.bin(), &["rmi".into(), image])
273 .map(|o| o.status.success())
274 .unwrap_or(false);
275 if let Ok(ctx) = cache_context_dir() {
276 let _ = std::fs::remove_dir_all(&ctx);
277 }
278 }
279
280 output::emit(
281 "container_uninstall",
282 &UninstallResult {
283 runtime: runtime.label(),
284 container: args.common.name,
285 removed,
286 image_removed,
287 purged: args.purge,
288 },
289 )
290}
291
292#[derive(Serialize)]
295struct StatusResult {
296 runtime: &'static str,
297 container: String,
298 running: bool,
299 endpoint: String,
300 #[serde(skip_serializing_if = "Option::is_none")]
301 token: Option<String>,
302 #[serde(skip_serializing_if = "Option::is_none")]
303 client_command: Option<String>,
304}
305
306async fn status(args: StatusArgs) -> Result<(), Error> {
307 let runtime = resolve_runtime(args.common.runtime)?;
308 let running = container_running(runtime, &args.common.name);
309 let endpoint = endpoint_url(args.port);
310
311 let token = if running {
312 read_token(runtime, &args.common.name).await.ok()
313 } else {
314 None
315 };
316 let client_command = token.as_deref().map(|t| client_command(args.port, t));
317
318 output::emit(
319 "container_status",
320 &StatusResult {
321 runtime: runtime.label(),
322 container: args.common.name,
323 running,
324 endpoint,
325 token,
326 client_command,
327 },
328 )
329}
330
331fn logs(args: LogsArgs) -> Result<(), Error> {
334 let runtime = resolve_runtime(args.common.runtime)?;
335 let mut argv: Vec<String> = vec!["logs".into()];
336 if args.follow {
337 argv.push("-f".into());
338 }
339 argv.push(args.common.name);
340 exec_inherit(runtime.bin(), &argv)
341}
342
343fn resolve_runtime(explicit: Option<RuntimeArg>) -> Result<Runtime, Error> {
346 if let Some(r) = explicit {
347 return Ok(r.into());
348 }
349 if let Some(v) = std::env::var_os("AFHTTP_CONTAINER_RUNTIME") {
350 return runtime_from_str(v.to_string_lossy().trim());
351 }
352 if on_path("docker") {
353 Ok(Runtime::Docker)
354 } else if on_path("podman") {
355 Ok(Runtime::Podman)
356 } else if on_path("container") {
357 Ok(Runtime::Apple)
358 } else {
359 Err(Error::new(
360 ErrorCode::InvalidArgument,
361 "no container runtime found: install Docker, Podman, or Apple `container`, or pass --runtime",
362 ))
363 }
364}
365
366fn runtime_from_str(value: &str) -> Result<Runtime, Error> {
367 match value {
368 "docker" => Ok(Runtime::Docker),
369 "podman" => Ok(Runtime::Podman),
370 "apple" | "container" => Ok(Runtime::Apple),
371 other => Err(Error::new(
372 ErrorCode::InvalidArgument,
373 format!("invalid container runtime '{other}': expected docker, podman, or apple"),
374 )),
375 }
376}
377
378fn on_path(bin: &str) -> bool {
379 let Some(paths) = std::env::var_os("PATH") else {
380 return false;
381 };
382 std::env::split_paths(&paths).any(|dir| dir.join(bin).is_file())
383}
384
385fn start_daemon(runtime: Runtime) {
388 if runtime == Runtime::Apple {
389 let _ = capture(runtime.bin(), &["system".into(), "start".into()]);
390 }
391}
392
393fn image_tag() -> String {
396 format!("{IMAGE_REPO}:{VERSION}")
397}
398
399fn volume_name(name: &str) -> String {
400 format!("{name}-data")
401}
402
403fn endpoint_url(port: u16) -> String {
404 format!("ws://127.0.0.1:{port}")
405}
406
407fn client_command(port: u16, token: &str) -> String {
408 format!(
409 "afhttp fetch https://example.com --endpoint-url ws://127.0.0.1:{port} --token-secret {token}"
410 )
411}
412
413fn target_triple(runtime: Runtime, host_arch: &str) -> &'static str {
416 match runtime {
417 Runtime::Apple => "aarch64-unknown-linux-gnu",
418 Runtime::Docker | Runtime::Podman => match host_arch {
419 "aarch64" | "arm64" => "aarch64-unknown-linux-gnu",
420 _ => "x86_64-unknown-linux-gnu",
421 },
422 }
423}
424
425#[derive(Clone, Copy, Debug, PartialEq, Eq)]
427struct Backend {
428 name: &'static str,
429 build_arg: &'static str,
430}
431
432const BACKENDS: [Backend; 5] = [
433 Backend {
434 name: "chrome-headless-shell",
435 build_arg: "WITH_CHROME_HEADLESS_SHELL",
436 },
437 Backend {
438 name: "lightpanda",
439 build_arg: "WITH_LIGHTPANDA",
440 },
441 Backend {
442 name: "fingerprint-chromium",
443 build_arg: "WITH_FINGERPRINT_CHROMIUM",
444 },
445 Backend {
446 name: "camoufox",
447 build_arg: "WITH_CAMOUFOX",
448 },
449 Backend {
450 name: "kasmvnc",
451 build_arg: "WITH_KASMVNC",
452 },
453];
454
455fn resolve_backends(names: &[String]) -> Result<Vec<Backend>, Error> {
456 let mut out = Vec::with_capacity(names.len());
457 for name in names {
458 let backend = BACKENDS.iter().find(|b| b.name == name).ok_or_else(|| {
459 Error::new(
460 ErrorCode::InvalidArgument,
461 format!(
462 "unknown backend '{name}': expected one of {}",
463 BACKENDS
464 .iter()
465 .map(|b| b.name)
466 .collect::<Vec<_>>()
467 .join(", ")
468 ),
469 )
470 })?;
471 if !out.contains(backend) {
472 out.push(*backend);
473 }
474 }
475 Ok(out)
476}
477
478enum BuildSource<'a> {
483 Embedded { ctx: &'a Path, target: &'a str },
484 FromSource { ctx: &'a Path },
485}
486
487fn build_args(
488 image: &str,
489 runtime: Runtime,
490 source: BuildSource,
491 backends: &[Backend],
492) -> Vec<String> {
493 let mut a: Vec<String> = vec!["build".into()];
494 if runtime == Runtime::Apple {
495 a.push("--platform".into());
496 a.push("linux/arm64".into());
497 }
498 let ctx = match source {
499 BuildSource::Embedded { ctx, target } => {
500 a.push("--build-arg".into());
501 a.push("AFHTTP_BIN_FROM=downloader".into());
502 a.push("--build-arg".into());
503 a.push(format!("AFHTTP_VERSION={VERSION}"));
504 a.push("--build-arg".into());
505 a.push(format!("AFHTTP_TARGET={target}"));
506 ctx
507 }
508 BuildSource::FromSource { ctx } => {
509 a.push("--build-arg".into());
510 a.push("AFHTTP_BIN_FROM=builder".into());
511 ctx
512 }
513 };
514 for b in backends {
515 a.push("--build-arg".into());
516 a.push(format!("{}=1", b.build_arg));
517 }
518 a.push("-t".into());
519 a.push(image.to_string());
520 a.push("-f".into());
521 a.push(
522 ctx.join("container/docker/Dockerfile")
523 .to_string_lossy()
524 .into_owned(),
525 );
526 a.push(ctx.to_string_lossy().into_owned());
527 a
528}
529
530fn resolve_source_context(arg: Option<&str>) -> Result<PathBuf, Error> {
532 let dir = match arg {
533 Some(p) => PathBuf::from(p),
534 None => std::env::current_dir()
535 .map_err(|e| Error::new(ErrorCode::IoError, format!("cannot read current dir: {e}")))?,
536 };
537 let dockerfile = dir.join("container/docker/Dockerfile");
538 if !dockerfile.is_file() {
539 return Err(Error::new(
540 ErrorCode::InvalidArgument,
541 format!(
542 "--from-source needs a source checkout: {} not found \
543 (run from the spore root or pass --context <dir>)",
544 dockerfile.display()
545 ),
546 ));
547 }
548 Ok(dir)
549}
550
551fn run_args(
552 name: &str,
553 image: &str,
554 port: u16,
555 profile: &str,
556 shm_size: &str,
557 host_args: &[String],
558) -> Vec<String> {
559 let mut a: Vec<String> = vec![
560 "run".into(),
561 "-d".into(),
562 "--name".into(),
563 name.to_string(),
564 "-v".into(),
565 format!("{}:/data", volume_name(name)),
566 "-e".into(),
567 format!("AFHTTP_PORT={port}"),
568 "-e".into(),
569 format!("AFHTTP_PROFILE={profile}"),
570 "--shm-size".into(),
571 shm_size.to_string(),
572 "-p".into(),
573 format!("127.0.0.1:{port}:{port}"),
574 image.to_string(),
575 ];
576 a.extend(host_args.iter().cloned());
577 a
578}
579
580fn spawn_error(bin: &str, err: &std::io::Error) -> Error {
583 if err.kind() == std::io::ErrorKind::NotFound {
584 Error::new(
585 ErrorCode::InvalidArgument,
586 format!("container runtime `{bin}` not found on PATH"),
587 )
588 } else {
589 Error::new(
590 ErrorCode::IoError,
591 format!("spawning `{bin}` failed: {err}"),
592 )
593 }
594}
595
596fn exec_inherit(bin: &str, args: &[String]) -> Result<(), Error> {
598 let status = Command::new(bin)
599 .args(args)
600 .status()
601 .map_err(|e| spawn_error(bin, &e))?;
602 if status.success() {
603 Ok(())
604 } else {
605 Err(Error::new(
606 ErrorCode::InternalError,
607 format!("`{bin} {}` failed ({status})", args.join(" ")),
608 ))
609 }
610}
611
612fn capture(bin: &str, args: &[String]) -> Result<std::process::Output, Error> {
614 Command::new(bin)
615 .args(args)
616 .output()
617 .map_err(|e| spawn_error(bin, &e))
618}
619
620fn image_exists(runtime: Runtime, image: &str) -> bool {
621 capture(
622 runtime.bin(),
623 &["image".into(), "inspect".into(), image.to_string()],
624 )
625 .map(|o| o.status.success())
626 .unwrap_or(false)
627}
628
629fn container_running(runtime: Runtime, name: &str) -> bool {
630 capture(runtime.bin(), &["ps".into()])
632 .map(|o| String::from_utf8_lossy(&o.stdout).contains(name))
633 .unwrap_or(false)
634}
635
636async fn read_token(runtime: Runtime, name: &str) -> Result<String, Error> {
639 let argv = vec![
640 "exec".into(),
641 name.to_string(),
642 "cat".into(),
643 "/data/afhttp/host-token".into(),
644 ];
645 for attempt in 0..10 {
646 if let Ok(out) = capture(runtime.bin(), &argv) {
647 if out.status.success() {
648 let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
649 if !token.is_empty() {
650 return Ok(token);
651 }
652 }
653 }
654 if attempt < 9 {
655 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
656 }
657 }
658 Err(Error::new(
659 ErrorCode::InternalError,
660 format!("could not read host token from container `{name}`"),
661 ))
662}
663
664fn build_failed_error(target: &str) -> Error {
665 Error::new(
666 ErrorCode::InternalError,
667 format!(
668 "image build failed. If v{VERSION} has no published release asset for \
669 {target}, build from a source checkout instead: \
670 `afhttp container install --from-source` (or \
671 docker compose -f container/docker/compose.yaml up --build)"
672 ),
673 )
674}
675
676fn cache_context_dir() -> Result<PathBuf, Error> {
679 let base = std::env::var_os("XDG_CACHE_HOME")
680 .map(PathBuf::from)
681 .filter(|p| p.is_absolute())
682 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
683 .ok_or_else(|| {
684 Error::new(
685 ErrorCode::IoError,
686 "cannot resolve cache dir: set HOME or XDG_CACHE_HOME",
687 )
688 })?;
689 Ok(base.join("afhttp").join("container").join(VERSION))
690}
691
692fn write_build_context() -> Result<PathBuf, Error> {
693 let root = cache_context_dir()?;
694 let dir = root.join("container").join("docker");
698 std::fs::create_dir_all(&dir)?;
699 std::fs::write(dir.join("Dockerfile"), DOCKERFILE)?;
700 std::fs::write(dir.join("install-backends.sh"), INSTALL_BACKENDS)?;
701 std::fs::write(dir.join("entrypoint.sh"), ENTRYPOINT)?;
702 Ok(root)
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708
709 #[test]
710 fn runtime_from_str_parses_and_rejects() {
711 assert_eq!(runtime_from_str("docker").unwrap(), Runtime::Docker);
712 assert_eq!(runtime_from_str("podman").unwrap(), Runtime::Podman);
713 assert_eq!(runtime_from_str("apple").unwrap(), Runtime::Apple);
714 assert_eq!(runtime_from_str("container").unwrap(), Runtime::Apple);
715 assert_eq!(
716 runtime_from_str("nerdctl").unwrap_err().error_code,
717 ErrorCode::InvalidArgument
718 );
719 }
720
721 #[test]
722 fn explicit_runtime_wins_over_detection() {
723 assert_eq!(
724 resolve_runtime(Some(RuntimeArg::Apple)).unwrap(),
725 Runtime::Apple
726 );
727 assert_eq!(
728 resolve_runtime(Some(RuntimeArg::Docker)).unwrap(),
729 Runtime::Docker
730 );
731 }
732
733 #[test]
734 fn target_triple_tracks_runtime_and_arch() {
735 assert_eq!(
736 target_triple(Runtime::Apple, "x86_64"),
737 "aarch64-unknown-linux-gnu"
738 );
739 assert_eq!(
740 target_triple(Runtime::Docker, "aarch64"),
741 "aarch64-unknown-linux-gnu"
742 );
743 assert_eq!(
744 target_triple(Runtime::Docker, "x86_64"),
745 "x86_64-unknown-linux-gnu"
746 );
747 assert_eq!(
749 target_triple(Runtime::Podman, "aarch64"),
750 "aarch64-unknown-linux-gnu"
751 );
752 assert_eq!(
753 target_triple(Runtime::Podman, "x86_64"),
754 "x86_64-unknown-linux-gnu"
755 );
756 }
757
758 #[test]
759 fn backend_names_map_to_build_args_and_reject_unknown() {
760 let resolved = resolve_backends(&["camoufox".into(), "kasmvnc".into()]).unwrap();
761 assert_eq!(resolved.len(), 2);
762 assert_eq!(resolved[0].build_arg, "WITH_CAMOUFOX");
763 assert_eq!(resolved[1].build_arg, "WITH_KASMVNC");
764
765 let deduped = resolve_backends(&["camoufox".into(), "camoufox".into()]).unwrap();
767 assert_eq!(deduped.len(), 1);
768
769 assert_eq!(
770 resolve_backends(&["nope".into()]).unwrap_err().error_code,
771 ErrorCode::InvalidArgument
772 );
773 }
774
775 #[test]
776 fn embedded_build_args_include_version_target_and_apple_platform() {
777 let ctx = PathBuf::from("/cache/ctx");
778 let backends = resolve_backends(&["lightpanda".into()]).unwrap();
779 let docker = build_args(
780 "afhttp-host:1.2.3",
781 Runtime::Docker,
782 BuildSource::Embedded {
783 ctx: &ctx,
784 target: "x86_64-unknown-linux-gnu",
785 },
786 &backends,
787 );
788 assert_eq!(docker[0], "build");
789 assert!(!docker.contains(&"--platform".to_string()));
790 assert!(docker.contains(&"AFHTTP_BIN_FROM=downloader".to_string()));
791 assert!(docker.contains(&format!("AFHTTP_VERSION={VERSION}")));
792 assert!(docker.contains(&"AFHTTP_TARGET=x86_64-unknown-linux-gnu".to_string()));
793 assert!(docker.contains(&"WITH_LIGHTPANDA=1".to_string()));
794 assert_eq!(
795 docker[docker.len() - 2],
796 "/cache/ctx/container/docker/Dockerfile"
797 );
798 assert_eq!(docker.last().unwrap(), "/cache/ctx");
799
800 let apple = build_args(
801 "afhttp-host:1.2.3",
802 Runtime::Apple,
803 BuildSource::Embedded {
804 ctx: &ctx,
805 target: "aarch64-unknown-linux-gnu",
806 },
807 &[],
808 );
809 let pos = apple.iter().position(|a| a == "--platform").unwrap();
810 assert_eq!(apple[pos + 1], "linux/arm64");
811 }
812
813 #[test]
814 fn from_source_build_args_use_canonical_dockerfile_no_release_args() {
815 let repo = PathBuf::from("/repo");
816 let backends = resolve_backends(&["camoufox".into()]).unwrap();
817 let args = build_args(
818 "afhttp-host:1.2.3",
819 Runtime::Podman,
820 BuildSource::FromSource { ctx: &repo },
821 &backends,
822 );
823 assert!(args.contains(&"AFHTTP_BIN_FROM=builder".to_string()));
825 assert!(!args.iter().any(|a| a.starts_with("AFHTTP_VERSION=")));
826 assert!(!args.iter().any(|a| a.starts_with("AFHTTP_TARGET=")));
827 assert!(args.contains(&"WITH_CAMOUFOX=1".to_string()));
828 assert_eq!(args[args.len() - 2], "/repo/container/docker/Dockerfile");
829 assert_eq!(args.last().unwrap(), "/repo");
830 assert!(!args.contains(&"--platform".to_string()));
832 }
833
834 #[test]
835 fn run_args_publish_loopback_and_pass_host_args() {
836 let a = run_args(
837 "afhttp-host",
838 "afhttp-host:1.2.3",
839 9222,
840 "work",
841 "1g",
842 &["--browser".into(), "camoufox".into()],
843 );
844 assert!(a.contains(&"afhttp-host-data:/data".to_string()));
845 assert!(a.contains(&"AFHTTP_PORT=9222".to_string()));
846 assert!(a.contains(&"AFHTTP_PROFILE=work".to_string()));
847 assert!(a.contains(&"127.0.0.1:9222:9222".to_string()));
848 let img = a.iter().position(|x| x == "afhttp-host:1.2.3").unwrap();
850 let br = a.iter().position(|x| x == "--browser").unwrap();
851 assert!(img < br);
852 }
853
854 #[test]
855 fn client_command_uses_loopback_endpoint() {
856 let cmd = client_command(9333, "deadbeef");
857 assert!(cmd.contains("--endpoint-url ws://127.0.0.1:9333"));
858 assert!(cmd.contains("--token-secret deadbeef"));
859 }
860
861 #[test]
862 fn build_failure_error_points_at_compose_fallback() {
863 let err = build_failed_error("aarch64-unknown-linux-gnu");
864 assert_eq!(err.error_code, ErrorCode::InternalError);
865 assert!(err.detail.contains("compose"));
866 assert!(err.detail.contains("aarch64-unknown-linux-gnu"));
867 }
868}