diff --git a/scripts/desktop-dev-migration.mjs b/scripts/desktop-dev-migration.mjs new file mode 100644 index 0000000000..99aa2455ae --- /dev/null +++ b/scripts/desktop-dev-migration.mjs @@ -0,0 +1,65 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +async function readOptionalJson(file) { + try { + return JSON.parse(await readFile(file, 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } +} + +function isProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error.code === 'ESRCH') return false; + throw error; + } +} + +// Tauri stops its frontend server when Desktop hands off to Data Migrator. +// Keep the development supervisor alive and re-enter Tauri after the migrator +// requests a restart, restoring both Vite and the Rust watcher. +export async function runDesktopWithMigrationRestart(run, { + info = () => {}, + isAlive = isProcessAlive, + wait = () => delay(250), +} = {}) { + let restartArgs = []; + for (;;) { + const directory = await mkdtemp(path.join(os.tmpdir(), 'openbitfun-dev-migration-')); + try { + let failure; + try { + await run(restartArgs, { OPENBITFUN_DEV_MIGRATION_DIR: directory }); + } catch (error) { + failure = error; + } + const handoff = await readOptionalJson(path.join(directory, 'handoff.json')); + if (!handoff) { + if (failure) throw failure; + return; + } + if (!UUID.test(handoff.runId) || !Number.isSafeInteger(handoff.pid) || handoff.pid <= 0) { + throw new Error('Invalid development migration handoff'); + } + info('Waiting for Data Migrator; Desktop will reopen automatically when it finishes'); + while (isAlive(handoff.pid)) await wait(); + const restart = await readOptionalJson(path.join(directory, 'restart.json')); + if (restart?.runId !== handoff.runId) { + throw new Error('Data Migrator exited without completing its restart handoff; run desktop:dev to retry'); + } + restartArgs = ['--legacy-migration-run-id', handoff.runId]; + info('Restarting Desktop through the development launcher'); + } finally { + await rm(directory, { recursive: true, force: true }); + } + } +} diff --git a/scripts/desktop-dev-migration.test.mjs b/scripts/desktop-dev-migration.test.mjs new file mode 100644 index 0000000000..6f064f228c --- /dev/null +++ b/scripts/desktop-dev-migration.test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { access, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { runDesktopWithMigrationRestart } from './desktop-dev-migration.mjs'; + +const runId = '01234567-89ab-4cde-8fab-0123456789ab'; +const write = (directory, file, value) => writeFile(path.join(directory, file), JSON.stringify(value)); + +test('normal exit does not restart and removes its temporary channel', async () => { + let directory; + let calls = 0; + await runDesktopWithMigrationRestart(async (args, env) => { + calls++; + assert.deepEqual(args, []); + directory = env.OPENBITFUN_DEV_MIGRATION_DIR; + }); + assert.equal(calls, 1); + await assert.rejects(access(directory), { code: 'ENOENT' }); +}); + +test('build failures remain failures when no migration was launched', async () => { + const failure = new Error('build failed'); + await assert.rejects(runDesktopWithMigrationRestart(async () => { throw failure; }), failure); +}); + +test('migration completion waits for child exit then restores the development host with the run id', async () => { + let calls = 0; + let directory; + let running = true; + await runDesktopWithMigrationRestart(async (args, env) => { + calls++; + if (calls === 1) { + directory = env.OPENBITFUN_DEV_MIGRATION_DIR; + await write(directory, 'handoff.json', { runId, pid: 123 }); + // A handoff may also make Tauri report its stopped frontend as a failure. + throw new Error('frontend stopped'); + } + assert.equal(running, false); + assert.deepEqual(args, ['--legacy-migration-run-id', runId]); + assert.notEqual(env.OPENBITFUN_DEV_MIGRATION_DIR, directory); + }, { + isAlive: (pid) => { assert.equal(pid, 123); return running; }, + wait: async () => { + await write(directory, 'restart.json', { runId }); + running = false; + }, + }); + assert.equal(calls, 2); + await assert.rejects(access(directory), { code: 'ENOENT' }); +}); + +test('crashed or mismatched migrators cannot silently restart Desktop', async () => { + for (const restart of [null, { runId: 'wrong-run' }]) { + await assert.rejects(runDesktopWithMigrationRestart(async (_, env) => { + await write(env.OPENBITFUN_DEV_MIGRATION_DIR, 'handoff.json', { runId, pid: 123 }); + if (restart) await write(env.OPENBITFUN_DEV_MIGRATION_DIR, 'restart.json', restart); + }, { isAlive: () => false }), /without completing its restart handoff/); + } +}); diff --git a/scripts/dev.cjs b/scripts/dev.cjs index 494668ddfc..e3672f494c 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -124,6 +124,7 @@ function spawnCommand(cmd, args, cwd = ROOT_DIR, envOverrides = {}, shell = fals const child = spawn(cmd, args, { cwd, stdio: 'inherit', + windowsHide: true, shell, env: { ...process.env, @@ -192,6 +193,7 @@ function spawnBackgroundCommand(cmd, args, cwd = ROOT_DIR, env = process.env) { return spawn(cmd, args, { cwd, stdio: 'inherit', + windowsHide: true, env, }); } @@ -208,6 +210,7 @@ function spawnWindowsCommandArgs(command, args, cwd = ROOT_DIR, env = process.en return spawn(process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe', ['/d', '/s', '/c', command, ...args], { cwd, stdio: 'inherit', + windowsHide: true, env, }); } @@ -554,30 +557,31 @@ async function startDesktopPreview() { printInfo(`Launching debug desktop binary: ${desktopBinary}`); - appProcess = spawnBackgroundCommand(desktopBinary, [], ROOT_DIR, { - ...process.env, - // Debug previews must upload the current workspace build. The adjacent - // target/debug resource tree is only a build-time copy and can lag behind - // mobile-web edits made while the desktop binary is being reused. - OPENBITFUN_MOBILE_WEB_DIR: path.join(ROOT_DIR, 'src/mobile-web/dist'), - }); - - appProcess.on('error', (error) => { - printError(`Desktop preview failed to start: ${error.message || String(error)}`); - void shutdown(1); - }); - - appProcess.on('exit', (code, signal) => { - if (!shuttingDown) { - printInfo(`Desktop preview exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`); - } - void shutdown(code ?? 0); - }); - - printSuccess('Desktop preview is running'); - printInfo('Front-end edits continue to use Vite HMR; rebuild Rust only when desktop-side code changes'); - - await new Promise(() => {}); + const { runDesktopWithMigrationRestart } = await import( + pathToFileURL(path.join(__dirname, 'desktop-dev-migration.mjs')).href + ); + try { + await runDesktopWithMigrationRestart((restartArgs, migrationEnv) => new Promise((resolve, reject) => { + appProcess = spawnBackgroundCommand(desktopBinary, restartArgs, ROOT_DIR, { + ...process.env, + ...migrationEnv, + // Upload the current workspace mobile bundle instead of the staged copy. + OPENBITFUN_MOBILE_WEB_DIR: path.join(ROOT_DIR, 'src/mobile-web/dist'), + }); + appProcess.on('error', reject); + appProcess.on('close', (code, signal) => { + appProcess = null; + if (code === 0) resolve(); + else reject(new Error(`Desktop preview exited (code=${code}, signal=${signal})`)); + }); + printSuccess('Desktop preview is running'); + printInfo('Front-end edits continue to use Vite HMR; rebuild Rust only when desktop-side code changes'); + }), { info: printInfo }); + await shutdown(0); + } catch (error) { + printError(error.message || String(error)); + await shutdown(1); + } } /** @@ -747,19 +751,26 @@ async function main() { OPENBITFUN_MOBILE_WEB_DIR: path.join(ROOT_DIR, 'src/mobile-web/dist'), }; try { - if (process.platform === 'win32') { - // Running the generated .cmd shim directly via spawn is flaky on Windows. - // Use cmd.exe with an explicit args array so the desktop app directory - // stays the Tauri project root without pnpm workspace path rewriting. - const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri.cmd'); - await runWindowsCommandArgs(tauriBin, ['dev', '--config', tauriConfig], desktopDir, tauriDevEnv); - } else { - const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri'); - await spawnCommand(tauriBin, ['dev', '--config', tauriConfig], desktopDir, { - CARGO_PROFILE_DEV_CODEGEN_UNITS: tauriDevEnv.CARGO_PROFILE_DEV_CODEGEN_UNITS, - OPENBITFUN_MOBILE_WEB_DIR: tauriDevEnv.OPENBITFUN_MOBILE_WEB_DIR, - }); - } + const { runDesktopWithMigrationRestart } = await import( + pathToFileURL(path.join(__dirname, 'desktop-dev-migration.mjs')).href + ); + await runDesktopWithMigrationRestart(async (restartArgs, migrationEnv) => { + const args = ['dev', '--config', tauriConfig, ...(restartArgs.length ? ['--', '--', ...restartArgs] : [])]; + if (process.platform === 'win32') { + // Running the generated .cmd shim directly via spawn is flaky on Windows. + // Use cmd.exe with an explicit args array so the desktop app directory + // stays the Tauri project root without pnpm workspace path rewriting. + const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri.cmd'); + await runWindowsCommandArgs(tauriBin, args, desktopDir, { ...tauriDevEnv, ...migrationEnv }); + } else { + const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri'); + await spawnCommand(tauriBin, args, desktopDir, { + CARGO_PROFILE_DEV_CODEGEN_UNITS: tauriDevEnv.CARGO_PROFILE_DEV_CODEGEN_UNITS, + OPENBITFUN_MOBILE_WEB_DIR: tauriDevEnv.OPENBITFUN_MOBILE_WEB_DIR, + ...migrationEnv, + }); + } + }, { info: printInfo }); } finally { // Option B: prune only when the desktop:dev session ends, not on each rebuild. await runDesktopTargetGc('debug'); diff --git a/src/apps/data-migrator/AGENTS.md b/src/apps/data-migrator/AGENTS.md index 5593d2ca29..55f856171d 100644 --- a/src/apps/data-migrator/AGENTS.md +++ b/src/apps/data-migrator/AGENTS.md @@ -30,8 +30,15 @@ must remain a separate executable and WebView identity from Desktop. ```bash cargo test -p openbitfun-data-migrator node --test scripts/data-migrator-tauri-build.test.mjs +node --test scripts/desktop-dev-migration.test.mjs ``` +Completion always returns to Desktop. Debug builds launched through `desktop:dev` +or `desktop:preview:debug` ask that launcher to restart via its private temporary +handoff directory, preserving the frontend server and development lifecycle. +Builds without that channel restart the trusted sibling Desktop executable. +Keep this developer-only channel out of persisted migration and remote protocols. + Run `pnpm run check:core-boundaries` when dependencies or delivery-profile selection change. Packaging, signing, and UI interaction are separate explicit verification steps. diff --git a/src/apps/data-migrator/src/app_state.rs b/src/apps/data-migrator/src/app_state.rs index 83df00a76e..c4d6a04d3f 100644 --- a/src/apps/data-migrator/src/app_state.rs +++ b/src/apps/data-migrator/src/app_state.rs @@ -32,24 +32,6 @@ const DATA_MIGRATOR_BINARY_NAME: &str = match option_env!("OPENBITFUN_DATA_MIGRA None => "openbitfun-data-migrator", }; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum FinishAction { - CloseForDevRestart, - RestartDesktop, -} - -const fn finish_action() -> FinishAction { - finish_action_for_build(cfg!(debug_assertions)) -} - -const fn finish_action_for_build(is_debug_build: bool) -> FinishAction { - if is_debug_build { - FinishAction::CloseForDevRestart - } else { - FinishAction::RestartDesktop - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CommandError { @@ -553,6 +535,14 @@ impl MigratorCoordinator { pub(crate) fn finish_and_restart( &self, choice: MigrationPromptChoice, + ) -> Result<(), CommandError> { + self.finish_with_restart(choice, || self.restart_desktop()) + } + + fn finish_with_restart( + &self, + choice: MigrationPromptChoice, + restart: impl FnOnce() -> LegacyMigrationResult<()>, ) -> Result<(), CommandError> { if self.is_running() { return Err(CommandError::operation_in_progress()); @@ -574,10 +564,7 @@ impl MigratorCoordinator { let result = (|| { self.persist_prompt_choice(choice)?; - match finish_action() { - FinishAction::CloseForDevRestart => Ok(()), - FinishAction::RestartDesktop => self.restart_desktop(), - } + restart() })(); if let Err(error) = result { let mut session = self.lock(); @@ -772,6 +759,10 @@ impl MigratorCoordinator { fn restart_desktop(&self) -> LegacyMigrationResult<()> { let run_id = self.lock().request.run_id.clone(); + #[cfg(debug_assertions)] + if let Some(directory) = std::env::var_os("OPENBITFUN_DEV_MIGRATION_DIR") { + return request_dev_restart(Path::new(&directory), &run_id); + } let current = std::env::current_exe().map_err(|error| LegacyMigrationError::Io { path: Path::new(DATA_MIGRATOR_BINARY_NAME).to_path_buf(), source: error, @@ -815,6 +806,31 @@ impl MigratorCoordinator { } } +#[cfg(debug_assertions)] +fn request_dev_restart(directory: &Path, run_id: &str) -> LegacyMigrationResult<()> { + // Only the development supervisor supplies this private, per-launch channel. + // The migration request never supplies paths or executable names. + let handoff_path = directory.join("handoff.json"); + let bytes = std::fs::read(&handoff_path).map_err(|source| LegacyMigrationError::Io { + path: handoff_path, + source, + })?; + let handoff: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| { + LegacyMigrationError::InvalidRequest("invalid development restart handoff".to_string()) + })?; + if handoff["runId"].as_str() != Some(run_id) + || handoff["pid"].as_u64() != Some(u64::from(std::process::id())) + { + return Err(LegacyMigrationError::InvalidRequest( + "development restart handoff does not match this migrator".to_string(), + )); + } + openbitfun_legacy_migration::atomic_write_json( + &directory.join("restart.json"), + &serde_json::json!({ "runId": run_id }), + ) +} + fn migration_engine( roots: MigrationRoots, selection: &MigrationSelection, @@ -879,7 +895,7 @@ fn snapshot(session: &MigratorSession) -> MigratorView { let report = session.report.as_ref().map(redact_report_for_ui); MigratorView { delivery_profile: DeliveryProfile::DataMigrator.id().to_string(), - restart_desktop_on_finish: finish_action() == FinishAction::RestartDesktop, + restart_desktop_on_finish: true, protocol: MigratorProtocolCapabilities::current(), mode: session.request.mode, source: session.source.clone(), @@ -1032,7 +1048,7 @@ mod tests { let view = coordinator.snapshot(); assert_eq!(view.delivery_profile, "data-migrator"); - assert!(!view.restart_desktop_on_finish); + assert!(view.restart_desktop_on_finish); assert_eq!(view.mode, MigratorRequestMode::Onboarding); assert!(view.source.is_some()); assert!(!view.recovery); @@ -1127,8 +1143,91 @@ mod tests { } #[test] - fn finish_action_preserves_release_restart_and_closes_debug() { - assert_eq!(finish_action(), FinishAction::CloseForDevRestart); - assert_eq!(finish_action_for_build(false), FinishAction::RestartDesktop); + fn dismissing_onboarding_persists_choice_and_restarts_desktop() { + for choice in [ + MigrationPromptChoice::DoNotRemind, + MigrationPromptChoice::RemindLater, + ] { + let temporary = tempfile::tempdir().unwrap(); + let roots = fixture_roots(temporary.path()); + write_probe_fixture(&roots); + let request = handoff_request(); + HandoffStore::new(roots.clone(), "openbitfun", "stable") + .write_request(&request, now_ms()) + .unwrap(); + let coordinator = MigratorCoordinator::bootstrap_with( + &request.run_id, + roots.clone(), + "openbitfun", + "stable", + ) + .unwrap(); + let mut restarted = false; + coordinator + .finish_with_restart(choice, || { + // Restart must observe the saved choice and one-time restart receipt. + let state = MigrationOnboardingStore::new(roots.clone()).load().unwrap(); + assert_eq!(state.choice, choice); + assert_eq!( + state.handled_run_id.as_deref(), + Some(request.run_id.as_str()) + ); + restarted = true; + Ok(()) + }) + .unwrap(); + assert!(restarted); + assert!(coordinator.snapshot().restart_desktop_on_finish); + } + } + + #[test] + fn failed_restart_stays_visible_and_keeps_the_saved_choice() { + let temporary = tempfile::tempdir().unwrap(); + let roots = fixture_roots(temporary.path()); + write_probe_fixture(&roots); + let request = handoff_request(); + HandoffStore::new(roots.clone(), "openbitfun", "stable") + .write_request(&request, now_ms()) + .unwrap(); + let coordinator = MigratorCoordinator::bootstrap_with( + &request.run_id, + roots.clone(), + "openbitfun", + "stable", + ) + .unwrap(); + assert!(coordinator + .finish_with_restart(MigrationPromptChoice::DoNotRemind, || { + Err(LegacyMigrationError::TrustedInstallationUnavailable( + "missing desktop".into(), + )) + }) + .is_err()); + assert!(coordinator.snapshot().error.is_some()); + assert_eq!( + MigrationOnboardingStore::new(roots).load().unwrap().choice, + MigrationPromptChoice::DoNotRemind + ); + } + + #[test] + #[cfg(debug_assertions)] + fn development_restart_requires_matching_handoff() { + let temporary = tempfile::tempdir().unwrap(); + let run_id = uuid::Uuid::new_v4().to_string(); + let handoff = temporary.path().join("handoff.json"); + openbitfun_legacy_migration::atomic_write_json( + &handoff, + &serde_json::json!({ "runId": run_id, "pid": std::process::id() }), + ) + .unwrap(); + assert!(request_dev_restart(temporary.path(), "wrong-run").is_err()); + assert!(!temporary.path().join("restart.json").exists()); + request_dev_restart(temporary.path(), &run_id).unwrap(); + let restart: serde_json::Value = + serde_json::from_slice(&std::fs::read(temporary.path().join("restart.json")).unwrap()) + .unwrap(); + assert_eq!(restart["runId"], run_id); } } diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 187f2ca93a..6d1f3e66bd 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -56,6 +56,11 @@ pnpm run desktop:preview:debug pnpm run prepare:dsh-profile # optional: local DeepSeek Harness sessions ``` +Both development launchers supervise legacy Data Migrator handoffs. Dismissing +or finishing migration reopens Desktop automatically; `desktop:dev` restores +Vite and the Rust watcher. Verify this orchestration with +`node --test scripts/desktop-dev-migration.test.mjs`. + ## Fast builds | Command | When to use | diff --git a/src/apps/desktop/src/api/legacy_migration_api.rs b/src/apps/desktop/src/api/legacy_migration_api.rs index 6107cf2c01..28cdfb3983 100644 --- a/src/apps/desktop/src/api/legacy_migration_api.rs +++ b/src/apps/desktop/src/api/legacy_migration_api.rs @@ -495,7 +495,16 @@ fn launch_data_migrator(run_id: &str) -> LegacyMigrationResult { DESKTOP_BINARY_NAME, DATA_MIGRATOR_BINARY_NAME, )?; - launch_trusted_executable(&executable, &[OsStr::new(run_id)]) + let pid = launch_trusted_executable(&executable, &[OsStr::new(run_id)])?; + #[cfg(debug_assertions)] + if let Some(directory) = std::env::var_os("OPENBITFUN_DEV_MIGRATION_DIR") { + let path = std::path::PathBuf::from(directory).join("handoff.json"); + openbitfun_legacy_migration::atomic_write_json( + &path, + &serde_json::json!({ "runId": run_id, "pid": pid }), + )?; + } + Ok(pid) } fn should_offer_onboarding( diff --git a/src/crates/services/legacy-migration/AGENTS.md b/src/crates/services/legacy-migration/AGENTS.md new file mode 100644 index 0000000000..fab8de408d --- /dev/null +++ b/src/crates/services/legacy-migration/AGENTS.md @@ -0,0 +1,21 @@ +# Legacy migration service + +This crate owns local offline migration primitives, authenticated handoffs, and +writer-process inspection. Keep product selection and UI in their existing +assembly and app owners. + +Process inspection must fail explicitly when inventory is unavailable. Never +interpret an inspection error as an empty writer list. On macOS, inspect executable +names with the system `ps`, preserving full bundle paths and excluding arguments. + +## Focused verification + +For process inventory, writer classification, and handoff changes: + +```bash +cargo test -p openbitfun-legacy-migration --lib handoff::tests +``` + +The macOS inventory test exercises the real host process list and must run on +macOS; parser and classification fixtures run on all test platforms. These local +checks do not establish remote-workspace, remote-control, peer, or dispatch behavior. diff --git a/src/crates/services/legacy-migration/src/handoff.rs b/src/crates/services/legacy-migration/src/handoff.rs index b717d67f64..a3c7f220ee 100644 --- a/src/crates/services/legacy-migration/src/handoff.rs +++ b/src/crates/services/legacy-migration/src/handoff.rs @@ -712,9 +712,59 @@ fn platform_process_entries() -> LegacyMigrationResult> { #[cfg(target_os = "macos")] fn platform_process_entries() -> LegacyMigrationResult> { - Err(LegacyMigrationError::ProcessInspection( - "process inventory is not implemented for macOS".to_string(), - )) + // macOS has no /proc. Use the system ps with unlimited output width so + // application bundle paths (including spaces) cannot truncate writer names. + // `comm` excludes arguments, which may contain user content or credentials. + let output = openbitfun_services_core::process_manager::create_command("/bin/ps") + .args(["-ww", "-axo", "pid=,comm="]) + .env("LC_ALL", "C") + .stdin(std::process::Stdio::null()) + .output() + .map_err(|_| { + LegacyMigrationError::ProcessInspection("could not run system ps".to_string()) + })?; + if !output.status.success() { + return Err(LegacyMigrationError::ProcessInspection( + "system ps failed to enumerate processes".to_string(), + )); + } + parse_macos_process_entries(&String::from_utf8_lossy(&output.stdout)) +} + +#[cfg(any(target_os = "macos", test))] +fn parse_macos_process_entries(output: &str) -> LegacyMigrationResult> { + let invalid_inventory = || { + LegacyMigrationError::ProcessInspection( + "system ps returned an invalid inventory".to_string(), + ) + }; + let mut entries = Vec::new(); + for line in output.lines().filter(|line| !line.trim().is_empty()) { + let (pid, command) = line + .trim_start() + .split_once(char::is_whitespace) + .ok_or_else(invalid_inventory)?; + let process_id = pid + .parse::() + .ok() + .filter(|pid| *pid > 0) + .ok_or_else(invalid_inventory)?; + let executable_name = command + .trim_start() + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .ok_or_else(invalid_inventory)? + .to_string(); + entries.push(ProcessEntry { + process_id, + executable_name, + }); + } + if entries.is_empty() { + return Err(invalid_inventory()); + } + Ok(entries) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -885,6 +935,66 @@ mod tests { }; use std::collections::BTreeSet; + #[test] + fn macos_inventory_preserves_bundle_names_and_writer_classification() { + let entries = parse_macos_process_entries( + " 42 /Users/test/Development Projects/target/debug/openbitfun-desktop\n\ + 43 /Applications/BitFun.app/Contents/MacOS/BitFun\n\ + 44 /Applications/Custom Product.app/Contents/MacOS/Custom Product\n\ + 45 /Applications/OpenBitFun.app/Contents/MacOS/openbitfun-data-migrator\n\ + 46 /usr/bin/unrelated\n", + ) + .expect("parse process inventory"); + let blockers = classify_writer_processes(&entries, 42, 45, &["Custom Product"]); + assert_eq!( + blockers + .iter() + .map(|entry| entry.process_id) + .collect::>(), + vec![42, 43, 44] + ); + assert!(blockers[0].is_handoff_caller); + assert_eq!(blockers[1].executable_name, "BitFun"); + assert_eq!(blockers[2].executable_name, "Custom Product"); + } + + #[test] + fn macos_inventory_rejects_empty_or_malformed_output() { + for output in [ + "", + " \n", + "42", + "0 /bin/app", + "pid /bin/app", + "42 ", + "42 /", + "42 /bin/app\ninvalid", + ] { + assert!( + matches!( + parse_macos_process_entries(output), + Err(LegacyMigrationError::ProcessInspection(_)) + ), + "accepted invalid process inventory: {output:?}" + ); + } + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_process_inventory_includes_current_executable() { + let entries = platform_process_entries().expect("inspect macOS processes"); + let current = entries + .iter() + .find(|entry| entry.process_id == std::process::id()) + .expect("current process is present"); + let executable = std::env::current_exe().expect("current executable"); + assert_eq!( + current.executable_name, + executable.file_name().unwrap().to_string_lossy() + ); + } + fn roots(root: &Path) -> MigrationRoots { MigrationRoots { legacy_user_root: root.join("legacy/user"),