Skip to main content

fission_cli/
lib.rs

1use anyhow::Result;
2use clap::Parser;
3use std::path::Path;
4
5mod cli;
6
7#[cfg(test)]
8use fission_command_core::{read_project_config, Target};
9
10use cli::{Cli, Command, ServerCommand, SiteCommand};
11
12pub fn run<I, S>(args: I) -> Result<()>
13where
14    I: IntoIterator<Item = S>,
15    S: Into<std::ffi::OsString> + Clone,
16{
17    let mut argv: Vec<std::ffi::OsString> = args.into_iter().map(Into::into).collect();
18    if let Some(bin) = argv.first() {
19        if let Some(name) = Path::new(bin).file_name().and_then(|value| value.to_str()) {
20            if name == "cargo-fission" {
21                argv[0] = std::ffi::OsString::from("fission");
22                if argv.get(1).and_then(|value| value.to_str()) == Some("fission") {
23                    argv.remove(1);
24                }
25            }
26        }
27    }
28    let cli = Cli::parse_from(argv);
29    match cli.command {
30        Command::Init {
31            path,
32            name,
33            app_id,
34            local_path,
35        } => fission_command_core::init_project(&path, name, app_id, local_path),
36        Command::AddTarget {
37            targets,
38            project_dir,
39        } => fission_command_core::add_targets(&project_dir, &targets),
40        Command::AddCapability {
41            capabilities,
42            project_dir,
43        } => fission_command_core::add_capabilities(&project_dir, &capabilities),
44        Command::Doctor {
45            targets,
46            project_dir,
47            strict,
48        } => fission_command_run::doctor::run_doctor(&project_dir, &targets, strict),
49        Command::Devices { project_dir, json } => {
50            fission_command_run::list_devices(&project_dir, json)
51        }
52        Command::Run {
53            target,
54            device,
55            project_dir,
56            detach,
57            release,
58            host,
59            port,
60            no_open,
61            headless,
62        } => fission_command_run::run_app(fission_command_run::RunOptions {
63            project_dir,
64            target,
65            device,
66            detach,
67            release,
68            host,
69            port,
70            no_open,
71            headless,
72        }),
73        Command::Build {
74            target,
75            project_dir,
76            release,
77        } => fission_command_run::build_app(fission_command_run::BuildOptions {
78            project_dir,
79            target,
80            release,
81        }),
82        Command::Test {
83            target,
84            project_dir,
85            headless,
86        } => fission_command_run::test_app(fission_command_run::TestOptions {
87            project_dir,
88            target,
89            headless,
90        }),
91        Command::Site { command } => match command {
92            SiteCommand::Build {
93                project_dir,
94                release,
95            } => fission_command_site::build(&project_dir, release),
96            SiteCommand::Check {
97                project_dir,
98                release,
99            } => fission_command_site::check(&project_dir, release),
100            SiteCommand::Serve {
101                project_dir,
102                host,
103                port,
104                release,
105                no_open,
106            } => fission_command_site::serve(&project_dir, release, host, port, !no_open),
107            SiteCommand::Routes { project_dir } => fission_command_site::routes(&project_dir),
108        },
109        Command::Server { command } => match command {
110            ServerCommand::Build {
111                project_dir,
112                release,
113            } => fission_command_server::build(&project_dir, release),
114            ServerCommand::Check {
115                project_dir,
116                release,
117            } => fission_command_server::check(&project_dir, release),
118            ServerCommand::Serve {
119                project_dir,
120                host,
121                port,
122                release,
123            } => fission_command_server::serve(&project_dir, release, host, port),
124            ServerCommand::Routes { project_dir } => fission_command_server::routes(&project_dir),
125            ServerCommand::Artifacts {
126                project_dir,
127                release,
128                no_compile,
129            } => fission_command_server::artifacts(&project_dir, release, !no_compile),
130        },
131        Command::Package {
132            target,
133            format,
134            project_dir,
135            release,
136            json,
137        } => fission_command_package::package(fission_command_package::PackageOptions {
138            project_dir,
139            target,
140            format,
141            release,
142            json,
143        }),
144        Command::Distribute {
145            action,
146            provider,
147            artifact,
148            site,
149            deploy,
150            track,
151            dry_run,
152            yes,
153            project_dir,
154            json,
155        } => fission_command_package::distribute(fission_command_package::DistributeOptions {
156            project_dir,
157            provider,
158            action: action.unwrap_or(fission_command_package::DistributeAction::Publish),
159            artifact,
160            site,
161            deploy,
162            track,
163            dry_run,
164            yes,
165            json,
166        }),
167        Command::Publish {
168            provider,
169            artifact,
170            site,
171            deploy,
172            track,
173            dry_run,
174            yes,
175            project_dir,
176            json,
177        } => fission_command_package::distribute(fission_command_package::DistributeOptions {
178            project_dir,
179            provider,
180            action: fission_command_package::DistributeAction::Publish,
181            artifact,
182            site,
183            deploy,
184            track,
185            dry_run,
186            yes,
187            json,
188        }),
189        Command::Readiness {
190            kind,
191            target,
192            format,
193            provider,
194            artifact,
195            site,
196            track,
197            project_dir,
198            json,
199        } => fission_command_package::readiness(fission_command_package::ReadinessOptions {
200            project_dir,
201            kind,
202            target,
203            format,
204            provider,
205            artifact,
206            site,
207            track,
208            json,
209        }),
210        Command::ReleaseConfig { command } => fission_command_release::release_config(command),
211        Command::ReleaseContent { command } => fission_command_release::release_content(command),
212        Command::Beta { command } => fission_command_release::beta(command),
213        Command::Signing { command } => fission_command_release::signing(command),
214        Command::Reviews { command } => fission_command_release::reviews(command),
215        Command::ReleaseWorkflow { command } => fission_command_release::release_workflow(command),
216        Command::Auth { command } => fission_command_release::auth(command),
217        Command::Logs {
218            target,
219            device,
220            project_dir,
221            follow,
222        } => fission_command_run::attach_logs(fission_command_run::LogOptions {
223            project_dir,
224            target,
225            device,
226            follow,
227        }),
228        Command::Ui {
229            project_dir,
230            screenshot,
231            exit_after_render,
232            width,
233            height,
234        } => fission_command_ui::run_ui(fission_command_ui::UiOptions {
235            project_dir,
236            screenshot,
237            exit_after_render,
238            width,
239            height,
240        }),
241        Command::ServeWeb {
242            project_dir,
243            host,
244            port,
245            open,
246        } => fission_command_run::serve_web(fission_command_run::ServeWebOptions {
247            project_dir,
248            host,
249            port,
250            open,
251        }),
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use std::{fs, path::PathBuf, process::Command};
259
260    fn unique_dir(name: &str) -> PathBuf {
261        let dir =
262            std::env::temp_dir().join(format!("cargo-fission-{}-{}", name, std::process::id()));
263        let _ = fs::remove_dir_all(&dir);
264        dir
265    }
266
267    #[cfg(unix)]
268    fn write_executable(path: impl AsRef<std::path::Path>, contents: &str) {
269        use std::os::unix::fs::PermissionsExt;
270
271        let path = path.as_ref();
272        if let Some(parent) = path.parent() {
273            fs::create_dir_all(parent).unwrap();
274        }
275        fs::write(path, contents).unwrap();
276        fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
277    }
278
279    #[cfg(unix)]
280    fn path_with_fake_bin(fake_bin: &std::path::Path) -> String {
281        let existing = std::env::var("PATH").unwrap_or_default();
282        format!("{}:{existing}", fake_bin.display())
283    }
284
285    #[cfg(unix)]
286    fn write_fake_cargo(fake_bin: &std::path::Path) {
287        write_executable(
288            fake_bin.join("cargo"),
289            r#"#!/usr/bin/env bash
290set -euo pipefail
291if [[ "${1:-}" == "metadata" ]]; then
292  printf '%s\n' "$FAKE_TARGET_DIR"
293  exit 0
294fi
295if [[ "${1:-}" == "build" ]]; then
296  target=""
297  package=""
298  profile="debug"
299  args=("$@")
300  for ((i = 0; i < ${#args[@]}; i++)); do
301    case "${args[$i]}" in
302      --target) target="${args[$((i + 1))]}" ;;
303      --package) package="${args[$((i + 1))]}" ;;
304      --release) profile="release" ;;
305    esac
306  done
307  if [[ -z "$target" || -z "$package" ]]; then
308    printf 'fake cargo expected --target and --package\n' >&2
309    exit 2
310  fi
311  artifact_dir="$FAKE_TARGET_DIR/$target/$profile"
312  mkdir -p "$artifact_dir"
313  lib_name="${package//-/_}"
314  printf 'fake native library\n' > "$artifact_dir/lib${lib_name}.so"
315  printf '#!/usr/bin/env sh\nexit 0\n' > "$artifact_dir/$package"
316  chmod +x "$artifact_dir/$package"
317  exit 0
318fi
319printf 'unsupported fake cargo invocation: %s\n' "$*" >&2
320exit 2
321"#,
322        );
323    }
324
325    #[cfg(unix)]
326    fn write_fake_python3(fake_bin: &std::path::Path) {
327        write_executable(
328            fake_bin.join("python3"),
329            r#"#!/usr/bin/env bash
330set -euo pipefail
331script=$(cat)
332if [[ "$script" == *"import plistlib"* ]]; then
333  printf 'plistlib must not be used by generated mobile package scripts\n' >&2
334  exit 44
335fi
336if [[ "$script" == *"cargo\", \"metadata\""* || "$script" == *"metadata = json.loads"* ]]; then
337  printf '%s\n' "$FAKE_TARGET_DIR"
338  exit 0
339fi
340if [[ "$script" == *"android:minSdkVersion"* ]]; then
341  if [[ "${1:-}" == "-" ]]; then
342    shift
343  fi
344  source="$1"
345  dest="$2"
346  min_api="$3"
347  target_api="$4"
348  has_code=false
349  if [[ -f "$(dirname "$dest")/apk-root/classes.dex" ]]; then
350    has_code=true
351  fi
352  sed -E \
353    -e "s/android:minSdkVersion=\"[0-9]+\"/android:minSdkVersion=\"$min_api\"/" \
354    -e "s/android:targetSdkVersion=\"[0-9]+\"/android:targetSdkVersion=\"$target_api\"/" \
355    -e "s/android:hasCode=\"(true|false)\"/android:hasCode=\"$has_code\"/" \
356    "$source" > "$dest"
357  exit 0
358fi
359printf 'unsupported fake python3 script\n%s\n' "$script" >&2
360exit 2
361"#,
362        );
363    }
364
365    #[cfg(unix)]
366    fn write_fake_ios_tools(fake_bin: &std::path::Path) {
367        write_executable(
368            fake_bin.join("xcrun"),
369            r#"#!/usr/bin/env bash
370set -euo pipefail
371if [[ "${1:-}" == "--find" && "${2:-}" == "plutil" ]]; then
372  printf '%s/plutil\n' "$FISSION_FAKE_BIN"
373  exit 0
374fi
375if [[ "${1:-}" == "--find" && "${2:-}" == "ibtool" ]]; then
376  printf '%s/ibtool\n' "$FISSION_FAKE_BIN"
377  exit 0
378fi
379printf 'unsupported fake xcrun invocation: %s\n' "$*" >&2
380exit 2
381"#,
382        );
383        write_executable(
384            fake_bin.join("plutil"),
385            r#"#!/usr/bin/env bash
386set -euo pipefail
387if [[ "${1:-}" != "-replace" || "${3:-}" != "-string" ]]; then
388  printf 'unsupported fake plutil invocation: %s\n' "$*" >&2
389  exit 2
390fi
391key="$2"
392value="$4"
393file="$5"
394tmp="$(mktemp)"
395awk -v key="$key" -v value="$value" '
396  replace_next {
397    print "  <string>" value "</string>"
398    replace_next = 0
399    next
400  }
401  {
402    print
403    if ($0 ~ "<key>" key "</key>") {
404      replace_next = 1
405    }
406  }
407' "$file" > "$tmp"
408mv "$tmp" "$file"
409"#,
410        );
411        write_executable(
412            fake_bin.join("ibtool"),
413            r#"#!/usr/bin/env bash
414set -euo pipefail
415args=("$@")
416for ((i = 0; i < ${#args[@]}; i++)); do
417  if [[ "${args[$i]}" == "--compile" ]]; then
418    mkdir -p "${args[$((i + 1))]}"
419    exit 0
420  fi
421done
422printf 'fake ibtool missing --compile\n' >&2
423exit 2
424"#,
425        );
426    }
427
428    #[cfg(unix)]
429    fn write_fake_android_tools(android_home: &std::path::Path, fake_bin: &std::path::Path) {
430        let build_tools = android_home.join("build-tools/35.0.0");
431        let ndk_bin = android_home.join("ndk/27.0.0/toolchains/llvm/prebuilt/linux-x86_64/bin");
432        fs::create_dir_all(android_home.join("platforms/android-35")).unwrap();
433        fs::write(android_home.join("platforms/android-35/android.jar"), "").unwrap();
434        fs::create_dir_all(&build_tools).unwrap();
435        fs::create_dir_all(&ndk_bin).unwrap();
436        fs::write(ndk_bin.join("aarch64-linux-android24-clang"), "").unwrap();
437        fs::write(ndk_bin.join("llvm-ar"), "").unwrap();
438
439        write_executable(
440            build_tools.join("aapt"),
441            r#"#!/usr/bin/env bash
442set -euo pipefail
443out=""
444manifest=""
445args=("$@")
446for ((i = 0; i < ${#args[@]}; i++)); do
447  case "${args[$i]}" in
448    -F) out="${args[$((i + 1))]}" ;;
449    -M) manifest="${args[$((i + 1))]}" ;;
450  esac
451done
452if [[ -z "$out" || -z "$manifest" ]]; then
453  printf 'fake aapt missing -F or -M\n' >&2
454  exit 2
455fi
456mkdir -p "$(dirname "$out")"
457{
458  printf 'AAPT_MANIFEST_BEGIN\n'
459  cat "$manifest"
460  printf '\nAAPT_MANIFEST_END\n'
461} > "$out"
462"#,
463        );
464        write_executable(
465            build_tools.join("zipalign"),
466            r#"#!/usr/bin/env bash
467set -euo pipefail
468args=("$@")
469count=${#args[@]}
470input="${args[$((count - 2))]}"
471output="${args[$((count - 1))]}"
472cp "$input" "$output"
473"#,
474        );
475        write_executable(
476            build_tools.join("apksigner"),
477            r#"#!/usr/bin/env bash
478set -euo pipefail
479out=""
480input=""
481args=("$@")
482for ((i = 0; i < ${#args[@]}; i++)); do
483  if [[ "${args[$i]}" == "--out" ]]; then
484    out="${args[$((i + 1))]}"
485  fi
486done
487input="${args[$((${#args[@]} - 1))]}"
488if [[ -z "$out" ]]; then
489  printf 'fake apksigner missing --out\n' >&2
490  exit 2
491fi
492cp "$input" "$out"
493"#,
494        );
495        write_executable(
496            fake_bin.join("zip"),
497            r#"#!/usr/bin/env bash
498set -euo pipefail
499archive=""
500entries=()
501for arg in "$@"; do
502  if [[ "$arg" == -* ]]; then
503    continue
504  fi
505  if [[ -z "$archive" ]]; then
506    archive="$arg"
507  else
508    entries+=("$arg")
509  fi
510done
511if [[ -z "$archive" ]]; then
512  printf 'fake zip missing archive\n' >&2
513  exit 2
514fi
515for entry in "${entries[@]}"; do
516  if [[ -d "$entry" ]]; then
517    while IFS= read -r file; do
518      printf 'APK_ENTRY=%s\n' "$file" >> "$archive"
519    done < <(find "$entry" -type f | sort)
520  elif [[ -f "$entry" ]]; then
521    printf 'APK_ENTRY=%s\n' "$entry" >> "$archive"
522  fi
523done
524	"#,
525        );
526        write_executable(
527            fake_bin.join("gradle"),
528            r#"#!/usr/bin/env bash
529set -euo pipefail
530project=""
531args=("$@")
532for ((i = 0; i < ${#args[@]}; i++)); do
533  if [[ "${args[$i]}" == "-p" ]]; then
534    project="${args[$((i + 1))]}"
535  fi
536done
537if [[ -z "$project" ]]; then
538  printf 'fake gradle missing -p project\n' >&2
539  exit 2
540fi
541variant="debug"
542for arg in "$@"; do
543  if [[ "$arg" == *"assembleRelease"* ]]; then
544    variant="release"
545  fi
546done
547apk="$project/app/build/outputs/apk/$variant/app-$variant.apk"
548mkdir -p "$(dirname "$apk")"
549{
550  printf 'GRADLE_MANIFEST_BEGIN\n'
551  cat "$project/AndroidManifest.xml"
552  printf '\nGRADLE_MANIFEST_END\n'
553  if [[ -d "$project/app/src/main/jniLibs" ]]; then
554    (cd "$project/app/src/main/jniLibs" && find . -type f | sort | sed 's#^\./#APK_ENTRY=lib/#')
555  fi
556} > "$apk"
557"#,
558        );
559    }
560
561    #[test]
562    fn init_creates_project_files() {
563        let dir = unique_dir("init");
564        run([
565            "fission",
566            "init",
567            dir.to_str().unwrap(),
568            "--name",
569            "hello-fission",
570        ])
571        .unwrap();
572
573        assert!(dir.join("Cargo.toml").exists());
574        assert!(dir.join("src/main.rs").exists());
575        assert!(dir.join("src/lib.rs").exists());
576        assert!(dir.join("src/app.rs").exists());
577        assert!(dir.join("assets/app-icon.png").exists());
578        assert!(dir.join("fission.toml").exists());
579        assert!(dir.join("platforms/windows/README.md").exists());
580        assert!(dir.join("platforms/macos/README.md").exists());
581        assert!(dir.join("platforms/linux/README.md").exists());
582        let readme = std::fs::read_to_string(dir.join("README.md")).unwrap();
583        let agents = std::fs::read_to_string(dir.join("AGENTS.md")).unwrap();
584        assert!(agents.contains("# Fission App Guidelines"));
585        assert!(agents.contains("#[fission_component]"));
586        assert!(agents.contains("Use Fission's native Router and RouterParams"));
587        assert!(readme.contains("fission devices --project-dir ."));
588        assert!(readme.contains("fission run --project-dir ."));
589        assert!(readme.contains("fission logs --target <target>"));
590        assert!(readme.contains("fission build --target <target>"));
591        assert!(readme.contains("fission test --target <target>"));
592        let manifest = std::fs::read_to_string(dir.join("Cargo.toml")).unwrap();
593        assert!(manifest.contains("default-features = false"));
594        assert!(manifest.contains("features = [\"desktop\"]"));
595    }
596
597    #[test]
598    fn init_writes_agents_to_git_root() {
599        let repo = unique_dir("init-agents-root");
600        fs::create_dir_all(repo.join(".git")).unwrap();
601        let app = repo.join("apps/todo");
602
603        run(["fission", "init", app.to_str().unwrap(), "--name", "todo"]).unwrap();
604
605        assert!(repo.join("AGENTS.md").exists());
606        assert!(!app.join("AGENTS.md").exists());
607    }
608
609    #[test]
610    fn init_uses_fission_agents_name_when_repo_agents_exists() {
611        let repo = unique_dir("init-agents-existing");
612        fs::create_dir_all(repo.join(".git")).unwrap();
613        fs::write(repo.join("AGENTS.md"), "existing repo instructions").unwrap();
614        let app = repo.join("apps/todo");
615
616        run(["fission", "init", app.to_str().unwrap(), "--name", "todo"]).unwrap();
617
618        assert_eq!(
619            fs::read_to_string(repo.join("AGENTS.md")).unwrap(),
620            "existing repo instructions"
621        );
622        let fission_agents = fs::read_to_string(repo.join("AGENTS.fission.md")).unwrap();
623        assert!(fission_agents.contains("# Fission App Guidelines"));
624        assert!(!app.join("AGENTS.md").exists());
625    }
626
627    #[test]
628    fn add_target_updates_manifest_and_scaffold() {
629        let dir = unique_dir("targets");
630        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
631        run([
632            "fission",
633            "add-target",
634            "web",
635            "ios",
636            "android",
637            "--project-dir",
638            dir.to_str().unwrap(),
639        ])
640        .unwrap();
641
642        let project = read_project_config(&dir).unwrap();
643        assert!(project.targets.contains(&Target::Web));
644        assert!(project.targets.contains(&Target::Ios));
645        assert!(project.targets.contains(&Target::Android));
646        let manifest = std::fs::read_to_string(dir.join("Cargo.toml")).unwrap();
647        assert!(manifest.contains("default-features = false"));
648        assert!(manifest.contains("features = [\"desktop\", \"web\", \"android\", \"ios\"]"));
649        assert!(dir.join("platforms/web/README.md").exists());
650        assert!(dir.join("platforms/web/index.html").exists());
651        assert!(dir.join("platforms/web/bootstrap.mjs").exists());
652        assert!(dir.join("platforms/web/build-wasm.sh").exists());
653        assert!(dir.join("platforms/web/run-browser.sh").exists());
654        assert!(dir.join("platforms/web/test-browser.sh").exists());
655        assert!(dir.join("platforms/ios/README.md").exists());
656        assert!(dir.join("platforms/ios/Info.plist").exists());
657        assert!(dir.join("platforms/ios/LaunchScreen.storyboard").exists());
658        assert!(dir.join("platforms/ios/package-sim.sh").exists());
659        assert!(dir.join("platforms/ios/run-sim.sh").exists());
660        assert!(dir.join("platforms/ios/test-sim.sh").exists());
661        assert!(dir.join("platforms/ios/Package.swift").exists());
662        assert!(dir
663            .join("platforms/ios/Sources/FissionHost/FissionNativeCapabilities.swift")
664            .exists());
665        assert!(dir
666            .join(
667                "platforms/ios/NativeModules/Sources/FissionNativeModules/FissionNativeCapabilities.swift"
668            )
669            .exists());
670        assert!(dir.join("platforms/android/README.md").exists());
671        assert!(dir.join("platforms/android/AndroidManifest.xml").exists());
672        assert!(dir.join("platforms/android/settings.gradle.kts").exists());
673        assert!(dir.join("platforms/android/build.gradle.kts").exists());
674        assert!(dir.join("platforms/android/gradle.properties").exists());
675        assert!(dir.join("platforms/android/app/build.gradle.kts").exists());
676        assert!(dir.join("platforms/android/native-modules.gradle").exists());
677        assert!(dir
678            .join("platforms/android/java/rs/fission/runtime/FissionActivity.java")
679            .exists());
680        assert!(dir
681            .join("platforms/android/native-modules/README.md")
682            .exists());
683        assert!(dir.join("platforms/android/res/values/colors.xml").exists());
684        assert!(dir.join("platforms/android/res/values/styles.xml").exists());
685        assert!(dir
686            .join("platforms/android/res/drawable/fission_splash_background.xml")
687            .exists());
688        assert!(dir.join("platforms/android/package-apk.sh").exists());
689        assert!(dir.join("platforms/android/run-emulator.sh").exists());
690        assert!(dir.join("platforms/android/test-emulator.sh").exists());
691        let android_manifest =
692            std::fs::read_to_string(dir.join("platforms/android/AndroidManifest.xml")).unwrap();
693        assert!(android_manifest.contains("android:icon=\"@drawable/app_icon\""));
694        assert!(android_manifest.contains("android:hasCode=\"true\""));
695        assert!(android_manifest.contains("android:targetSdkVersion=\"35\""));
696        assert!(android_manifest.contains("android:theme=\"@style/FissionLaunchTheme\""));
697        assert!(android_manifest.contains("rs.fission.runtime.FissionActivity"));
698        let android_styles =
699            std::fs::read_to_string(dir.join("platforms/android/res/values/styles.xml")).unwrap();
700        assert!(android_styles.contains("android:windowBackground"));
701        assert!(android_styles.contains("android:windowSplashScreenAnimatedIcon"));
702        let android_package_script =
703            std::fs::read_to_string(dir.join("platforms/android/package-apk.sh")).unwrap();
704        assert!(android_package_script.contains("detect_android_toolchain"));
705        assert!(android_package_script
706            .contains("darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64"));
707        assert!(android_package_script.contains(
708            "ANDROID_MIN_API_LEVEL=\"${ANDROID_MIN_API_LEVEL:-${ANDROID_API_LEVEL:-24}}\""
709        ));
710        assert!(android_package_script.contains("ANDROID_TARGET_API_LEVEL="));
711        assert!(
712            android_package_script.contains("aarch64-linux-android${ANDROID_MIN_API_LEVEL}-clang")
713        );
714        assert!(android_package_script.contains("GRADLE_CMD"));
715        assert!(android_package_script.contains(":app:assemble"));
716        assert!(android_package_script.contains("app/src/main/jniLibs/arm64-v8a"));
717        assert!(android_package_script.contains("FISSION_GRADLE"));
718        assert!(android_package_script.contains("Gradle is required"));
719        let android_run_script =
720            std::fs::read_to_string(dir.join("platforms/android/run-emulator.sh")).unwrap();
721        assert!(android_run_script.contains("ANDROID_EMULATOR_API_LEVEL"));
722        assert!(android_run_script.contains("fission doctor android"));
723        assert!(android_run_script.contains("wait_for_android_boot()"));
724        assert!(android_run_script.contains("cmd package list packages"));
725        assert!(android_run_script.contains("ADB_INSTALL_FLAGS:---no-streaming -r"));
726        assert!(android_run_script.contains("rs.fission.runtime.FissionActivity"));
727        assert!(!android_run_script.contains("wait_for_android_boot() {\n  wait_for_android_boot"));
728        assert!(!android_run_script.contains("  wait_for_android_boot\n  wait_for_android_boot"));
729        assert!(
730            std::fs::read_to_string(dir.join("platforms/android/README.md"))
731                .unwrap()
732                .contains("fission run --target android")
733        );
734        let android_test_script =
735            std::fs::read_to_string(dir.join("platforms/android/test-emulator.sh")).unwrap();
736        assert!(android_test_script.contains("/health"));
737        let ios_package_script =
738            std::fs::read_to_string(dir.join("platforms/ios/package-sim.sh")).unwrap();
739        assert!(ios_package_script.contains("TARGET=\"${IOS_SIM_TARGET:-aarch64-apple-ios-sim}\""));
740        assert!(ios_package_script.contains("PROFILE=\"${IOS_SIM_PROFILE:-debug}\""));
741        assert!(ios_package_script.contains("BUNDLE_ID=\"${IOS_BUNDLE_ID:-com.example."));
742        assert!(ios_package_script.contains("DISPLAY_NAME=\"${IOS_DISPLAY_NAME:-"));
743        assert!(ios_package_script.contains("EXECUTABLE_NAME=\"${IOS_EXECUTABLE_NAME:-"));
744        assert!(ios_package_script.contains("xcrun --find plutil"));
745        assert!(ios_package_script.contains("-replace CFBundleIdentifier -string"));
746        assert!(!ios_package_script.contains("import plistlib"));
747        assert!(ios_package_script.contains("PkgInfo"));
748        assert!(ios_package_script.contains("PLATFORM_APP_ICONS"));
749        assert!(ios_package_script.contains("AppIcon.png"));
750        assert!(ios_package_script.contains("LaunchScreen.storyboard"));
751        assert!(ios_package_script.contains("ibtool"));
752        assert!(ios_package_script.contains("LaunchScreen.storyboardc"));
753        assert!(ios_package_script.contains("SplashImage.png"));
754        let ios_plist = std::fs::read_to_string(dir.join("platforms/ios/Info.plist")).unwrap();
755        assert!(ios_plist.contains("UILaunchStoryboardName"));
756        let ios_run_script = std::fs::read_to_string(dir.join("platforms/ios/run-sim.sh")).unwrap();
757        assert!(ios_run_script.contains("BUNDLE_ID=\"${IOS_BUNDLE_ID:-com.example."));
758        assert!(ios_run_script.contains("IOS_SIM_UNINSTALL_BEFORE_INSTALL"));
759        assert!(ios_run_script.contains(
760            "xcrun simctl launch --terminate-running-process \"$DEVICE_ID\" \"$BUNDLE_ID\""
761        ));
762        assert!(std::fs::read_to_string(dir.join("platforms/ios/README.md"))
763            .unwrap()
764            .contains("fission run --target ios"));
765        assert!(
766            std::fs::read_to_string(dir.join("platforms/ios/test-sim.sh"))
767                .unwrap()
768                .contains("/health")
769        );
770        assert!(
771            std::fs::read_to_string(dir.join("platforms/web/index.html"))
772                .unwrap()
773                .contains("../../assets/app-icon.png")
774        );
775        let web_index = std::fs::read_to_string(dir.join("platforms/web/index.html")).unwrap();
776        assert!(web_index.contains("id=\"fission-web-mount\""));
777        assert!(web_index.contains("height: 100vh"));
778        assert!(web_index.contains("outline: none"));
779        assert!(web_index.contains("touch-action: none"));
780        assert!(!web_index.contains("Generated by"));
781        let web_test_script =
782            std::fs::read_to_string(dir.join("platforms/web/test-browser.sh")).unwrap();
783        assert!(web_test_script.contains("--remote-debugging-port=\"$CDP_PORT\""));
784        assert!(web_test_script.contains("/json/list"));
785        assert!(std::fs::read_to_string(dir.join("platforms/web/README.md"))
786            .unwrap()
787            .contains("fission run --target web"));
788    }
789
790    #[test]
791    fn init_hardens_existing_android_native_only_scaffold() {
792        let dir = unique_dir("android-hardening");
793        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
794        run([
795            "fission",
796            "add-target",
797            "android",
798            "--project-dir",
799            dir.to_str().unwrap(),
800        ])
801        .unwrap();
802
803        let manifest_path = dir.join("platforms/android/AndroidManifest.xml");
804        let package_path = dir.join("platforms/android/package-apk.sh");
805        fs::write(
806            &manifest_path,
807            fs::read_to_string(&manifest_path).unwrap().replace(
808                "rs.fission.runtime.FissionActivity",
809                "android.app.NativeActivity",
810            ),
811        )
812        .unwrap();
813        fs::write(
814            &package_path,
815            fs::read_to_string(&package_path)
816                .unwrap()
817                .replace("import pathlib\n", "")
818                .replace(
819                    r#"has_code = "true" if pathlib.Path(dest).with_name("apk-root").joinpath("classes.dex").exists() else "false"
820manifest = re.sub(r'android:hasCode="(?:true|false)"', f'android:hasCode="{has_code}"', manifest)
821"#,
822                    "",
823                ),
824        )
825        .unwrap();
826
827        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
828
829        let manifest = fs::read_to_string(manifest_path).unwrap();
830        assert!(manifest.contains("android:hasCode=\"false\""));
831        assert!(manifest.contains("android.app.NativeActivity"));
832        let package_script = fs::read_to_string(package_path).unwrap();
833        assert!(package_script.contains(":app:assemble"));
834        assert!(package_script.contains("app/src/main/jniLibs/arm64-v8a"));
835        assert!(!package_script.contains("import pathlib"));
836    }
837
838    #[test]
839    fn init_hardens_existing_android_raw_package_script_resources() {
840        let dir = unique_dir("android-raw-resources");
841        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
842        run([
843            "fission",
844            "add-target",
845            "android",
846            "--project-dir",
847            dir.to_str().unwrap(),
848        ])
849        .unwrap();
850
851        let package_path = dir.join("platforms/android/package-apk.sh");
852        fs::write(
853            &package_path,
854            r#"#!/usr/bin/env bash
855set -euo pipefail
856SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
857ICON_SOURCE="${FISSION_APP_ICON:-$SCRIPT_DIR/icon.png}"
858APK_ROOT="$SCRIPT_DIR/build/debug/apk-root"
859SO_PATH="$SCRIPT_DIR/libexample.so"
860LIB_NAME="example"
861mkdir -p "$APK_ROOT/lib/arm64-v8a" "$APK_ROOT/res/drawable-nodpi"
862cp "$SO_PATH" "$APK_ROOT/lib/arm64-v8a/lib$LIB_NAME.so"
863cp "$ICON_SOURCE" "$APK_ROOT/res/drawable-nodpi/app_icon.png"
864"#,
865        )
866        .unwrap();
867
868        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
869
870        let package_script = fs::read_to_string(package_path).unwrap();
871        assert!(package_script.contains("SPLASH_IMAGES"));
872        assert!(package_script.contains(
873            "cp \"$ICON_SOURCE\" \"$APK_ROOT/res/drawable-nodpi/fission_splash_image.png\""
874        ));
875        assert!(package_script.contains("cp -R \"$SCRIPT_DIR/res/.\" \"$APK_ROOT/res/\""));
876    }
877
878    #[test]
879    fn init_hardens_existing_ios_package_script_without_replacing_user_files() {
880        let dir = unique_dir("ios-package-hardening");
881        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
882        run([
883            "fission",
884            "add-target",
885            "ios",
886            "--project-dir",
887            dir.to_str().unwrap(),
888        ])
889        .unwrap();
890        let script_path = dir.join("platforms/ios/package-sim.sh");
891        let mut script = fs::read_to_string(&script_path).unwrap();
892        script = script.replace(
893            r#"cp "$SCRIPT_DIR/Info.plist" "$BUNDLE_DIR/Info.plist"
894PLUTIL=$(xcrun --find plutil 2>/dev/null || command -v plutil || true)
895if [[ -z "$PLUTIL" ]]; then
896  printf 'plutil not found. Install Xcode command line tools to package the iOS simulator app.\n' >&2
897  exit 1
898fi
899"$PLUTIL" -replace CFBundleIdentifier -string "$BUNDLE_ID" "$BUNDLE_DIR/Info.plist"
900"$PLUTIL" -replace CFBundleDisplayName -string "$DISPLAY_NAME" "$BUNDLE_DIR/Info.plist"
901"$PLUTIL" -replace CFBundleName -string "$DISPLAY_NAME" "$BUNDLE_DIR/Info.plist"
902"$PLUTIL" -replace CFBundleExecutable -string "$EXECUTABLE_NAME" "$BUNDLE_DIR/Info.plist"
903"#,
904            r#"python3 - <<'PY' "$SCRIPT_DIR/Info.plist" "$BUNDLE_DIR/Info.plist" "$BUNDLE_ID" "$DISPLAY_NAME" "$EXECUTABLE_NAME"
905import plistlib
906import sys
907
908source, dest, bundle_id, display_name, executable_name = sys.argv[1:]
909with open(source, "rb") as handle:
910    plist = plistlib.load(handle)
911plist["CFBundleIdentifier"] = bundle_id
912plist["CFBundleDisplayName"] = display_name
913plist["CFBundleName"] = display_name
914plist["CFBundleExecutable"] = executable_name
915with open(dest, "wb") as handle:
916    plistlib.dump(plist, handle, sort_keys=False)
917PY
918"#,
919        );
920        fs::write(&script_path, script).unwrap();
921
922        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
923
924        let hardened = fs::read_to_string(script_path).unwrap();
925        assert!(hardened.contains("xcrun --find plutil"));
926        assert!(hardened.contains("-replace CFBundleExecutable -string"));
927        assert!(!hardened.contains("import plistlib"));
928    }
929
930    #[cfg(unix)]
931    #[test]
932    fn ios_package_script_executes_without_python_plistlib() {
933        let dir = unique_dir("ios-package-e2e");
934        run([
935            "fission",
936            "init",
937            dir.to_str().unwrap(),
938            "--name",
939            "ios-package-e2e",
940            "--app-id",
941            "com.example.iospackagee2e",
942        ])
943        .unwrap();
944        run([
945            "fission",
946            "add-target",
947            "ios",
948            "--project-dir",
949            dir.to_str().unwrap(),
950        ])
951        .unwrap();
952
953        let fake_bin = dir.join("fake-bin");
954        let fake_target = dir.join("fake-target");
955        fs::create_dir_all(&fake_bin).unwrap();
956        fs::create_dir_all(&fake_target).unwrap();
957        write_fake_cargo(&fake_bin);
958        write_fake_python3(&fake_bin);
959        write_fake_ios_tools(&fake_bin);
960
961        let output = Command::new("bash")
962            .arg("platforms/ios/package-sim.sh")
963            .current_dir(&dir)
964            .env("PATH", path_with_fake_bin(&fake_bin))
965            .env("FAKE_TARGET_DIR", &fake_target)
966            .env("FISSION_FAKE_BIN", &fake_bin)
967            .env("IOS_BUNDLE_ID", "com.example.overridden")
968            .env("IOS_DISPLAY_NAME", "Overridden iOS")
969            .env("IOS_EXECUTABLE_NAME", "OverriddenExecutable")
970            .output()
971            .unwrap();
972        assert!(
973            output.status.success(),
974            "package-sim.sh failed\nstdout:\n{}\nstderr:\n{}",
975            String::from_utf8_lossy(&output.stdout),
976            String::from_utf8_lossy(&output.stderr)
977        );
978
979        let bundle_dir = String::from_utf8(output.stdout).unwrap();
980        let bundle_dir = PathBuf::from(bundle_dir.trim());
981        assert!(bundle_dir.join("OverriddenExecutable").exists());
982        assert!(bundle_dir.join("LaunchScreen.storyboardc").exists());
983        let plist = fs::read_to_string(bundle_dir.join("Info.plist")).unwrap();
984        assert!(plist.contains("<string>com.example.overridden</string>"));
985        assert!(plist.contains("<string>Overridden iOS</string>"));
986        assert!(plist.contains("<string>OverriddenExecutable</string>"));
987    }
988
989    #[cfg(unix)]
990    #[test]
991    fn android_package_script_builds_native_only_apk_metadata() {
992        let dir = unique_dir("android-package-e2e");
993        run([
994            "fission",
995            "init",
996            dir.to_str().unwrap(),
997            "--name",
998            "android-package-e2e",
999            "--app-id",
1000            "com.example.androidpackagee2e",
1001        ])
1002        .unwrap();
1003        run([
1004            "fission",
1005            "add-target",
1006            "android",
1007            "--project-dir",
1008            dir.to_str().unwrap(),
1009        ])
1010        .unwrap();
1011
1012        let fake_bin = dir.join("fake-bin");
1013        let fake_target = dir.join("fake-target");
1014        let android_home = dir.join("fake-android-sdk");
1015        fs::create_dir_all(&fake_bin).unwrap();
1016        fs::create_dir_all(&fake_target).unwrap();
1017        write_fake_cargo(&fake_bin);
1018        write_fake_python3(&fake_bin);
1019        write_fake_android_tools(&android_home, &fake_bin);
1020        let keystore = dir.join("debug.keystore");
1021        fs::write(&keystore, "fake debug keystore").unwrap();
1022
1023        let output = Command::new("bash")
1024            .arg("platforms/android/package-apk.sh")
1025            .current_dir(&dir)
1026            .env("PATH", path_with_fake_bin(&fake_bin))
1027            .env("FAKE_TARGET_DIR", &fake_target)
1028            .env("ANDROID_HOME", &android_home)
1029            .env("ANDROID_DEBUG_KEYSTORE", &keystore)
1030            .output()
1031            .unwrap();
1032        assert!(
1033            output.status.success(),
1034            "package-apk.sh failed\nstdout:\n{}\nstderr:\n{}",
1035            String::from_utf8_lossy(&output.stdout),
1036            String::from_utf8_lossy(&output.stderr)
1037        );
1038
1039        let apk = String::from_utf8(output.stdout).unwrap();
1040        let apk = PathBuf::from(apk.trim());
1041        let apk_payload = fs::read_to_string(apk).unwrap();
1042        assert!(apk_payload.contains("android:hasCode=\"true\""));
1043        assert!(apk_payload.contains("android:minSdkVersion=\"24\""));
1044        assert!(apk_payload.contains("android:targetSdkVersion=\"35\""));
1045        assert!(apk_payload.contains("APK_ENTRY=lib/arm64-v8a/libandroid_package_e2e.so"));
1046        assert!(!apk_payload.contains("APK_ENTRY=classes.dex"));
1047    }
1048
1049    #[test]
1050    fn init_existing_project_enables_features_for_detected_mobile_targets() {
1051        let dir = unique_dir("init-mobile-features");
1052        fs::create_dir_all(dir.join("src")).unwrap();
1053        fs::create_dir_all(dir.join("platforms/android")).unwrap();
1054        fs::create_dir_all(dir.join("platforms/ios")).unwrap();
1055        fs::write(dir.join("src/main.rs"), "fn main() {}\n").unwrap();
1056        fs::write(
1057            dir.join("Cargo.toml"),
1058            r#"[package]
1059name = "existing-mobile"
1060version = "0.1.0"
1061edition = "2021"
1062
1063[dependencies]
1064fission = { version = "0.7.0", default-features = false, features = ["desktop"] }
1065"#,
1066        )
1067        .unwrap();
1068
1069        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1070
1071        let manifest = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
1072        assert!(manifest.contains("default-features = false"));
1073        assert!(manifest.contains(r#"features = ["desktop", "android", "ios"]"#));
1074    }
1075
1076    #[test]
1077    fn add_target_updates_multiline_fission_dependency_features() {
1078        let dir = unique_dir("multiline-features");
1079        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1080        fs::write(
1081            dir.join("Cargo.toml"),
1082            r#"[package]
1083name = "multiline-features"
1084version = "0.1.0"
1085edition = "2021"
1086
1087[dependencies]
1088anyhow = "1"
1089
1090[dependencies.fission]
1091version = "0.7.0"
1092default-features = true
1093features = ["desktop"]
1094"#,
1095        )
1096        .unwrap();
1097
1098        run([
1099            "fission",
1100            "add-target",
1101            "android",
1102            "ios",
1103            "--project-dir",
1104            dir.to_str().unwrap(),
1105        ])
1106        .unwrap();
1107
1108        let manifest = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
1109        assert!(manifest.contains("[dependencies.fission]"));
1110        assert!(manifest.contains("default-features = false"));
1111        assert!(manifest.contains(r#"features = ["desktop", "android", "ios"]"#));
1112    }
1113
1114    #[test]
1115    fn custom_icon_config_is_preserved_and_applied_to_mobile_scaffolds() {
1116        let dir = unique_dir("custom-icons");
1117        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1118        fs::copy(
1119            dir.join("assets/app-icon.png"),
1120            dir.join("assets/shared-icon.png"),
1121        )
1122        .unwrap();
1123        fs::copy(
1124            dir.join("assets/app-icon.png"),
1125            dir.join("assets/android-icon.png"),
1126        )
1127        .unwrap();
1128        fs::copy(
1129            dir.join("assets/app-icon.png"),
1130            dir.join("assets/ios-icon.png"),
1131        )
1132        .unwrap();
1133        let mut manifest = fs::read_to_string(dir.join("fission.toml")).unwrap();
1134        manifest.push_str(
1135            r##"
1136
1137[package.icons]
1138mode = "mixed"
1139source = "assets/shared-icon.png"
1140safe_zone = 0.72
1141allow_upscale = false
1142
1143[package.icons.android]
1144source = "assets/android-icon.png"
1145
1146[package.icons.ios]
1147source = "assets/ios-icon.png"
1148"##,
1149        );
1150        fs::write(dir.join("fission.toml"), manifest).unwrap();
1151
1152        run([
1153            "fission",
1154            "add-target",
1155            "ios",
1156            "android",
1157            "--project-dir",
1158            dir.to_str().unwrap(),
1159        ])
1160        .unwrap();
1161
1162        let manifest = fs::read_to_string(dir.join("fission.toml")).unwrap();
1163        assert!(manifest.contains("[package.icons]"));
1164        assert!(manifest.contains("source = \"assets/shared-icon.png\""));
1165        assert!(manifest.contains("[package.icons.android]"));
1166        assert!(manifest.contains("[package.icons.ios]"));
1167        assert!(dir
1168            .join("platforms/android/res/drawable-nodpi/app_icon.png")
1169            .exists());
1170        assert!(dir.join("platforms/ios/AppIcon.png").exists());
1171        let android_manifest =
1172            fs::read_to_string(dir.join("platforms/android/AndroidManifest.xml")).unwrap();
1173        assert!(android_manifest.contains("android:icon=\"@drawable/app_icon\""));
1174        let android_package =
1175            fs::read_to_string(dir.join("platforms/android/package-apk.sh")).unwrap();
1176        assert!(android_package.contains("APP_ICONS"));
1177        let ios_package = fs::read_to_string(dir.join("platforms/ios/package-sim.sh")).unwrap();
1178        assert!(ios_package.contains("PLATFORM_APP_ICONS"));
1179    }
1180
1181    #[test]
1182    fn custom_splash_config_updates_mobile_platform_files() {
1183        let dir = unique_dir("custom-splash");
1184        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1185        fs::copy(
1186            dir.join("assets/app-icon.png"),
1187            dir.join("assets/custom-splash.png"),
1188        )
1189        .unwrap();
1190        let mut manifest = fs::read_to_string(dir.join("fission.toml")).unwrap();
1191        manifest.push_str(
1192            r##"
1193
1194[app.splash]
1195background_color = "#123456"
1196image = "assets/custom-splash.png"
1197resize_mode = "cover"
1198android_animation_duration_ms = 650
1199"##,
1200        );
1201        fs::write(dir.join("fission.toml"), manifest).unwrap();
1202
1203        run([
1204            "fission",
1205            "add-target",
1206            "ios",
1207            "android",
1208            "--project-dir",
1209            dir.to_str().unwrap(),
1210        ])
1211        .unwrap();
1212
1213        let android_colors =
1214            fs::read_to_string(dir.join("platforms/android/res/values/colors.xml")).unwrap();
1215        assert!(android_colors.contains("#123456"));
1216        let android_styles =
1217            fs::read_to_string(dir.join("platforms/android/res/values/styles.xml")).unwrap();
1218        assert!(android_styles.contains("650"));
1219        assert!(dir
1220            .join("platforms/android/res/drawable-nodpi/fission_splash_image.png")
1221            .exists());
1222        let storyboard =
1223            fs::read_to_string(dir.join("platforms/ios/LaunchScreen.storyboard")).unwrap();
1224        assert!(storyboard.contains("scaleAspectFill"));
1225        assert!(storyboard.contains("red=\"0.070588\""));
1226        assert!(dir.join("platforms/ios/SplashImage.png").exists());
1227    }
1228
1229    #[test]
1230    fn add_capability_updates_project_and_platform_config() {
1231        let dir = unique_dir("capability");
1232        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1233        run([
1234            "fission",
1235            "add-target",
1236            "ios",
1237            "android",
1238            "--project-dir",
1239            dir.to_str().unwrap(),
1240        ])
1241        .unwrap();
1242        run([
1243            "fission",
1244            "add-capability",
1245            "nfc",
1246            "biometric",
1247            "bluetooth",
1248            "barcode-scanner",
1249            "camera",
1250            "geolocation",
1251            "haptics",
1252            "microphone",
1253            "volume-control",
1254            "wifi",
1255            "--project-dir",
1256            dir.to_str().unwrap(),
1257        ])
1258        .unwrap();
1259
1260        let project = read_project_config(&dir).unwrap();
1261        assert!(project
1262            .capabilities
1263            .contains(&fission_command_core::PlatformCapability::Nfc));
1264        assert!(project
1265            .capabilities
1266            .contains(&fission_command_core::PlatformCapability::Biometric));
1267        assert!(project
1268            .capabilities
1269            .contains(&fission_command_core::PlatformCapability::Bluetooth));
1270        assert!(project
1271            .capabilities
1272            .contains(&fission_command_core::PlatformCapability::BarcodeScanner));
1273        assert!(project
1274            .capabilities
1275            .contains(&fission_command_core::PlatformCapability::Camera));
1276        assert!(project
1277            .capabilities
1278            .contains(&fission_command_core::PlatformCapability::Geolocation));
1279        assert!(project
1280            .capabilities
1281            .contains(&fission_command_core::PlatformCapability::Haptics));
1282        assert!(project
1283            .capabilities
1284            .contains(&fission_command_core::PlatformCapability::Microphone));
1285        assert!(project
1286            .capabilities
1287            .contains(&fission_command_core::PlatformCapability::VolumeControl));
1288        assert!(project
1289            .capabilities
1290            .contains(&fission_command_core::PlatformCapability::Wifi));
1291
1292        let android_manifest =
1293            std::fs::read_to_string(dir.join("platforms/android/AndroidManifest.xml")).unwrap();
1294        assert!(android_manifest.contains("android.permission.NFC"));
1295        assert!(android_manifest.contains("android.hardware.nfc"));
1296        assert!(android_manifest.contains("android.permission.USE_BIOMETRIC"));
1297        assert!(android_manifest.contains("android.permission.BLUETOOTH_SCAN"));
1298        assert!(android_manifest.contains("android.permission.BLUETOOTH_CONNECT"));
1299        assert!(android_manifest.contains("android.hardware.bluetooth_le"));
1300        assert!(android_manifest.contains("android.permission.CAMERA"));
1301        assert!(android_manifest.contains("android.hardware.camera.flash"));
1302        assert!(android_manifest.contains("android.permission.ACCESS_FINE_LOCATION"));
1303        assert!(android_manifest.contains("android.permission.VIBRATE"));
1304        assert!(android_manifest.contains("android.permission.RECORD_AUDIO"));
1305        assert!(android_manifest.contains("android.permission.MODIFY_AUDIO_SETTINGS"));
1306        assert!(android_manifest.contains("android.permission.NEARBY_WIFI_DEVICES"));
1307        assert!(android_manifest.contains("android.permission.ACCESS_WIFI_STATE"));
1308        assert!(dir
1309            .join("platforms/android/java/rs/fission/runtime/FissionAndroidCapabilities.java")
1310            .exists());
1311
1312        let ios_info = std::fs::read_to_string(dir.join("platforms/ios/Info.plist")).unwrap();
1313        assert!(ios_info.contains("NFCReaderUsageDescription"));
1314        assert!(ios_info.contains("NSFaceIDUsageDescription"));
1315        assert!(ios_info.contains("NSBluetoothAlwaysUsageDescription"));
1316        assert!(ios_info.contains("NSCameraUsageDescription"));
1317        assert!(ios_info.contains("NSLocationWhenInUseUsageDescription"));
1318        assert!(ios_info.contains("NSMicrophoneUsageDescription"));
1319        let ios_entitlements =
1320            std::fs::read_to_string(dir.join("platforms/ios/Entitlements.plist")).unwrap();
1321        assert!(ios_entitlements.contains("com.apple.developer.nfc.readersession.formats"));
1322        assert!(ios_entitlements.contains("com.apple.developer.networking.wifi-info"));
1323    }
1324
1325    #[test]
1326    fn init_existing_project_preserves_user_files_and_detects_targets() {
1327        let dir = unique_dir("existing");
1328        fs::create_dir_all(dir.join("src")).unwrap();
1329        fs::create_dir_all(dir.join("platforms/web")).unwrap();
1330        fs::write(
1331            dir.join("Cargo.toml"),
1332            "[package]\nname = \"existing-web\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1333        )
1334        .unwrap();
1335        fs::write(dir.join("src/main.rs"), "fn main() {}\n").unwrap();
1336        fs::write(dir.join("src/lib.rs"), "pub fn existing() {}\n").unwrap();
1337        fs::write(dir.join("README.md"), "# keep me\n").unwrap();
1338        fs::write(
1339            dir.join("platforms/web/index.html"),
1340            "<!doctype html><title>keep me</title>\n",
1341        )
1342        .unwrap();
1343
1344        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1345
1346        assert_eq!(
1347            fs::read_to_string(dir.join("README.md")).unwrap(),
1348            "# keep me\n"
1349        );
1350        assert_eq!(
1351            fs::read_to_string(dir.join("src/main.rs")).unwrap(),
1352            "fn main() {}\n"
1353        );
1354        assert_eq!(
1355            fs::read_to_string(dir.join("src/lib.rs")).unwrap(),
1356            "pub fn existing() {}\n"
1357        );
1358        assert_eq!(
1359            fs::read_to_string(dir.join("platforms/web/index.html")).unwrap(),
1360            "<!doctype html><title>keep me</title>\n"
1361        );
1362
1363        let project = read_project_config(&dir).unwrap();
1364        assert_eq!(project.app.name, "existing-web");
1365        assert!(project.targets.contains(&Target::Web));
1366        assert!(project.targets.contains(&Target::Macos));
1367        assert!(project.targets.contains(&Target::Linux));
1368        assert!(project.targets.contains(&Target::Windows));
1369        assert!(dir.join("platforms/web/README.md").exists());
1370        assert!(dir.join("platforms/web/bootstrap.mjs").exists());
1371        assert!(dir.join("assets/app-icon.png").exists());
1372    }
1373
1374    #[test]
1375    fn init_existing_project_is_idempotent() {
1376        let dir = unique_dir("idempotent");
1377        run(["fission", "init", dir.to_str().unwrap(), "--name", "idem"]).unwrap();
1378        let manifest = fs::read_to_string(dir.join("fission.toml")).unwrap();
1379        let main = fs::read_to_string(dir.join("src/main.rs")).unwrap();
1380        let agents = fs::read_to_string(dir.join("AGENTS.md")).unwrap();
1381
1382        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1383
1384        assert_eq!(
1385            fs::read_to_string(dir.join("fission.toml")).unwrap(),
1386            manifest
1387        );
1388        assert_eq!(fs::read_to_string(dir.join("src/main.rs")).unwrap(), main);
1389        assert_eq!(fs::read_to_string(dir.join("AGENTS.md")).unwrap(), agents);
1390        assert!(!dir.join("AGENTS.fission.md").exists());
1391    }
1392
1393    #[test]
1394    fn add_target_preserves_existing_target_files() {
1395        let dir = unique_dir("preserve-target");
1396        run([
1397            "fission",
1398            "init",
1399            dir.to_str().unwrap(),
1400            "--name",
1401            "preserve-target",
1402        ])
1403        .unwrap();
1404        fs::create_dir_all(dir.join("platforms/web")).unwrap();
1405        fs::write(
1406            dir.join("platforms/web/index.html"),
1407            "<!doctype html><title>custom</title>\n",
1408        )
1409        .unwrap();
1410        fs::write(dir.join("README.md"), "# custom readme\n").unwrap();
1411
1412        run([
1413            "fission",
1414            "add-target",
1415            "web",
1416            "--project-dir",
1417            dir.to_str().unwrap(),
1418        ])
1419        .unwrap();
1420
1421        assert_eq!(
1422            fs::read_to_string(dir.join("platforms/web/index.html")).unwrap(),
1423            "<!doctype html><title>custom</title>\n"
1424        );
1425        assert_eq!(
1426            fs::read_to_string(dir.join("README.md")).unwrap(),
1427            "# custom readme\n"
1428        );
1429        assert!(dir.join("platforms/web/README.md").exists());
1430        assert!(dir.join("platforms/web/bootstrap.mjs").exists());
1431        let project = read_project_config(&dir).unwrap();
1432        assert!(project.targets.contains(&Target::Web));
1433    }
1434
1435    #[test]
1436    fn cargo_fission_alias_accepts_prefixed_subcommand() {
1437        let dir = unique_dir("cargo-fission");
1438        run([
1439            "cargo-fission",
1440            "fission",
1441            "init",
1442            dir.to_str().unwrap(),
1443            "--name",
1444            "cargo-fission-demo",
1445        ])
1446        .unwrap();
1447
1448        assert!(dir.join("Cargo.toml").exists());
1449        assert!(dir.join("fission.toml").exists());
1450    }
1451
1452    #[test]
1453    fn doctor_command_runs_in_non_strict_mode() {
1454        let dir = unique_dir("doctor");
1455        run(["fission", "init", dir.to_str().unwrap()]).unwrap();
1456        run([
1457            "fission",
1458            "doctor",
1459            "web",
1460            "--project-dir",
1461            dir.to_str().unwrap(),
1462        ])
1463        .unwrap();
1464    }
1465}