diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md index 44b5e13347..60fecc227b 100644 --- a/docs/adb-device-analysis.md +++ b/docs/adb-device-analysis.md @@ -97,12 +97,17 @@ existing analysis families directly, for example | `capture_adb_screen` | read | One `screencap -p` frame (exec-out, no device-side file) with dimensions | | `collect_adb_bugreport` | read | Device-generated bugreport zip, digested | | `push_adb_file` | **mutates device** | One local file to a device path (e.g. pushing tools into `/data/local/tmp`); checks for existing targets and refuses them without explicit overwrite (the check is not atomic); compares digests when device `sha256sum` is available | -| `install_adb_package` | **mutates device** | Local APK via `adb install [-r]`; preserves the device's Success/Failure verdict | +| `install_adb_package` | **mutates device** | Local APK via `adb install [-r]`; preserves its Success/Failure verdict, records `replace_requested`, and leaves actual replacement `null` because prior package state is not observed | | `uninstall_adb_package` | **mutates device** | Removes a package and its data for user 0 | | `start_adb_app` | **mutates device** | Resolves the package's launcher activity (`cmd package resolve-activity --brief`) and starts it (`am start -n`) | | `start_adb_activity` | **mutates device** | Starts an intent (`am start -a` with optional URI, component, and up to 16 typed extras); preserves the activity manager's verdict | | `stop_adb_app` | **mutates device** | Force-stops every process of a package, ending a dynamic-analysis run | +For `install_adb_package`, `replace_requested` records whether `-r` was sent. +The `replaced` result is `null`: the successful install response does not say +whether the package was installed fresh or replaced, and REA does not query the +device first. The install verdict and host APK digest remain available. + ## Interpret the result - **Directory names retain whitespace and newlines.** Symlink targets do too, diff --git a/docs/installation.md b/docs/installation.md index b84d6033a7..72bf11d3ef 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -577,8 +577,13 @@ sessions before removing that recovery directory and retrying. ### Launcher paths and troubleshooting -On macOS, REA uses -`/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` by default. +On macOS, REA checks executable Hopper launchers in this order: the canonical +system application, the canonical application installed by `rea setup` under +`~/Applications`, then other `Hopper*.app` bundles in `/Applications` and +`~/Applications`. The Homebrew cask location is an optional `rea doctor` +diagnostic; to use a Homebrew-installed launcher for CLI/MCP startup, set +`HOPPER_LAUNCHER_PATH` to the path reported by doctor. REA does not run Homebrew +while loading runtime configuration. On Linux, it prefers executable `/opt/hopper/bin/Hopper`, then executable `~/.local/share/rea/hopper/bin/Hopper`, followed by `/opt/hopper-v4/bin/hopper` and `/usr/bin/hopper`; if none exists, the former remains the diagnostic diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index 77b06e3c9f..8615e4cdf1 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -1222,15 +1222,21 @@ exit 1`, const apk = join(root, "app.apk"); await writeFile(apk, "apk"); const digest = createHash("sha256").update("apk").digest("hex"); - const installed = await evidenceResult(providerOver(binary), { + const execution = await providerOver(binary).execute({ operation: "install_adb_package", input: { serial: "emulator-5554", apk_path: apk, replace: false }, }); - expect(installed).toMatchObject({ + expect(execution.ok).toBe(true); + if (!execution.ok) throw execution.error; + expect(execution.value.result).toMatchObject({ bytes: 3, sha256: digest, - replaced: false, + replace_requested: false, + replaced: null, }); + expect(execution.value.limitations).toContain( + "Whether an existing package was replaced is unknown; replace_requested records only the adb install mode", + ); const { binary: failing } = await writeAdbStub( () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index 881842642d..395ec81256 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -1884,12 +1884,14 @@ export class AdbProvider { apk_path: apkPath, bytes: digest.bytes, sha256: digest.sha256, - replaced: input.replace, + replace_requested: input.replace, + replaced: null, }, provider, { limitations: [ "Install changes device state; the installed APK's digest is the host file's digest", + "Whether an existing package was replaced is unknown; replace_requested records only the adb install mode", ], }, ), diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts index 0fd444f34b..093063aaee 100644 --- a/src/application/Doctor.test.ts +++ b/src/application/Doctor.test.ts @@ -595,6 +595,28 @@ describe("doctor Linux and Hopper discovery", () => { }); describe("doctor installed Hopper discovery", () => { + it("checks the canonical user install after the system launcher", async () => { + const userPath = + "/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper"; + const checked: string[] = []; + const result = await runDoctor( + undefined, + host({ + homeDirectory: "/fixture/home", + executable: (candidate) => { + checked.push(candidate); + return Promise.resolve(candidate === userPath); + }, + }), + ); + + expect(result.hopperPath).toBe(userPath); + expect(checked.slice(0, 2)).toEqual([ + "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper", + userPath, + ]); + }); + it("detects a Homebrew cask installed outside /Applications", async () => { const path = "/opt/homebrew/Caskroom/hopper/Hopper Disassembler.app/Contents/MacOS/hopper"; diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index f5a6cd4bdb..c1e465a5e3 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -14,6 +14,10 @@ import { type JavaScriptRuntime, } from "../domain/runtimeVersion.js"; import { installedCaskAppdir, probeHomebrew } from "./homebrew.js"; +import { + macHopperApplicationRoots, + macHopperRenamedLauncherCandidates, +} from "./MacHopperPaths.js"; import { linuxHopperBinarySupported, linuxSharedLibrariesAvailable, @@ -622,19 +626,22 @@ const uncomposedLinuxDemoRuntimeCheck = (): Promise => }); const manualHopperPaths = async (home: string): Promise => { - const paths: string[] = []; - for (const root of ["/Applications", join(home, "Applications")]) { + const roots = macHopperApplicationRoots(home); + const bundleNamesByRoot = new Map(); + for (const root of roots) { try { - for (const entry of await readdir(root, { withFileTypes: true })) { - if (entry.isDirectory() && /^Hopper.*\.app$/i.test(entry.name)) - paths.push(join(root, entry.name, "Contents/MacOS/hopper")); - } + bundleNamesByRoot.set( + root, + (await readdir(root, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map(({ name }) => name), + ); } catch (cause: unknown) { // A missing or unreadable optional application directory is not fatal. void cause; } } - return paths; + return macHopperRenamedLauncherCandidates(home, bundleNamesByRoot); }; const uniqueLines = (value: string): string[] => [ diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts index 069525a330..070ce4809e 100644 --- a/src/application/DoctorDiagnostics.ts +++ b/src/application/DoctorDiagnostics.ts @@ -7,15 +7,16 @@ import { systemLinuxHopperLauncherCandidates, supportsReaHost, } from "./LinuxHopper.js"; +import { + MAC_HOPPER_SYSTEM_LAUNCHER, + macHopperCanonicalLauncherCandidates, +} from "./MacHopperPaths.js"; import type { DoctorCheck, DoctorHost, DoctorProviderInspection, } from "./Doctor.js"; -const DEFAULT_HOPPER = - "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper"; - /** Ordered environment diagnostics consumed by the Doctor report assembler. */ export interface DoctorDiagnostics { readonly checks: readonly DoctorCheck[]; @@ -150,10 +151,17 @@ const findHopper = async (host: DoctorHost): Promise => { const candidates = host.configuredHopperPath === undefined ? [ - DEFAULT_HOPPER, + ...(host.platform === "darwin" + ? macHopperCanonicalLauncherCandidates(host.homeDirectory) + : [MAC_HOPPER_SYSTEM_LAUNCHER]), ...(host.platform === "linux" ? linuxHopperLauncherCandidates(host.homeDirectory) : [...systemLinuxHopperLauncherCandidates]), + ...(host.platform === "darwin" + ? [] + : macHopperCanonicalLauncherCandidates(host.homeDirectory).slice( + 1, + )), ...(await host.manualHopperPaths()), ] : [host.configuredHopperPath]; diff --git a/src/application/MacHopper.ts b/src/application/MacHopper.ts index 858e7fe489..f6b60c71be 100644 --- a/src/application/MacHopper.ts +++ b/src/application/MacHopper.ts @@ -17,6 +17,7 @@ import { z } from "zod"; import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js"; import { macDeveloperIdRequirement } from "../process/MacCodeSignature.js"; +import { macHopperUserApplicationPath } from "./MacHopperPaths.js"; import { downloadHopperPackage, HOPPER_DOWNLOAD_BYTES, @@ -320,8 +321,7 @@ export const systemMacHopperInstallHost = ( } return failure; }, - destination: () => - join(homeDirectory, "Applications/Hopper Disassembler.app"), + destination: () => macHopperUserApplicationPath(homeDirectory), }; }; diff --git a/src/application/MacHopperPaths.test.ts b/src/application/MacHopperPaths.test.ts new file mode 100644 index 0000000000..f650971db5 --- /dev/null +++ b/src/application/MacHopperPaths.test.ts @@ -0,0 +1,56 @@ +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + macHopperCanonicalLauncherCandidates, + macHopperLauncherCandidates, + macHopperRenamedLauncherCandidates, +} from "./MacHopperPaths.js"; + +describe("macOS Hopper launcher paths", () => { + const home = "/fixture/home"; + + it("keeps canonical system and user candidates ahead of renamed bundles", () => { + expect(macHopperCanonicalLauncherCandidates(home)).toEqual([ + "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper", + "/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper", + ]); + expect( + macHopperRenamedLauncherCandidates( + home, + new Map([ + [ + "/Applications", + ["Hopper z.app", "Hopper a.app", "Hopper Disassembler.app"], + ], + ["/fixture/home/Applications", ["Hopper User.app"]], + ]), + ), + ).toEqual([ + "/Applications/Hopper a.app/Contents/MacOS/hopper", + "/Applications/Hopper z.app/Contents/MacOS/hopper", + "/fixture/home/Applications/Hopper User.app/Contents/MacOS/hopper", + ]); + }); + + it("always includes the installer's canonical user destination", () => { + expect(macHopperLauncherCandidates(home)).toContain( + "/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper", + ); + }); + + it("finds renamed user bundles through the runtime filesystem scan", async () => { + const temporary = await mkdtemp(join(tmpdir(), "rea-hopper-paths-")); + const appDirectory = join(temporary, "Applications", "Hopper Preview.app"); + try { + await mkdir(appDirectory, { recursive: true }); + expect([...macHopperLauncherCandidates(temporary)]).toContain( + join(appDirectory, "Contents/MacOS/hopper"), + ); + } finally { + await rm(temporary, { recursive: true, force: true }); + } + }); +}); diff --git a/src/application/MacHopperPaths.ts b/src/application/MacHopperPaths.ts new file mode 100644 index 0000000000..5ffa6ffd8d --- /dev/null +++ b/src/application/MacHopperPaths.ts @@ -0,0 +1,76 @@ +import { readdirSync } from "node:fs"; +import { join } from "node:path"; + +export const MAC_HOPPER_APPLICATION_DIRECTORY = "/Applications"; +export const MAC_HOPPER_APPLICATION_NAME = "Hopper Disassembler.app"; +export const MAC_HOPPER_USER_APPLICATIONS_DIRECTORY = "Applications"; + +export const MAC_HOPPER_SYSTEM_LAUNCHER = join( + MAC_HOPPER_APPLICATION_DIRECTORY, + MAC_HOPPER_APPLICATION_NAME, + "Contents/MacOS/hopper", +); + +export const macHopperUserApplicationPath = (homeDirectory: string): string => + join( + homeDirectory, + MAC_HOPPER_USER_APPLICATIONS_DIRECTORY, + MAC_HOPPER_APPLICATION_NAME, + ); + +export const macHopperApplicationRoots = ( + homeDirectory: string, +): readonly string[] => [ + MAC_HOPPER_APPLICATION_DIRECTORY, + join(homeDirectory, MAC_HOPPER_USER_APPLICATIONS_DIRECTORY), +]; + +export const macHopperCanonicalLauncherCandidates = ( + homeDirectory: string, +): readonly string[] => [ + MAC_HOPPER_SYSTEM_LAUNCHER, + join(macHopperUserApplicationPath(homeDirectory), "Contents/MacOS/hopper"), +]; + +/** Build deterministic candidates from the discovered app bundle names. */ +export const macHopperRenamedLauncherCandidates = ( + homeDirectory: string, + bundleNamesByRoot: ReadonlyMap, +): readonly string[] => + macHopperApplicationRoots(homeDirectory).flatMap((root) => + [...new Set(bundleNamesByRoot.get(root) ?? [])] + .filter( + (name) => + /^Hopper.*\.app$/iu.test(name) && + name.toLocaleLowerCase("en-US") !== + MAC_HOPPER_APPLICATION_NAME.toLocaleLowerCase("en-US"), + ) + .sort() + .map((name) => join(root, name, "Contents/MacOS/hopper")), + ); + +const localBundleNames = (root: string): readonly string[] => { + try { + return readdirSync(root, { withFileTypes: true }) + .filter( + (entry) => entry.isDirectory() && /^Hopper.*\.app$/iu.test(entry.name), + ) + .map(({ name }) => name); + } catch (cause: unknown) { + // Missing or unreadable optional app directories contain no candidates. + void cause; + return []; + } +}; + +/** Yield canonical paths first; enumerate renamed bundles only if needed. */ +export function* macHopperLauncherCandidates( + homeDirectory: string, +): Generator { + yield* macHopperCanonicalLauncherCandidates(homeDirectory); + for (const root of macHopperApplicationRoots(homeDirectory)) + yield* macHopperRenamedLauncherCandidates( + homeDirectory, + new Map([[root, localBundleNames(root)]]), + ); +} diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index 1eb6be56ba..d849e43983 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -1,5 +1,3 @@ -import { join } from "node:path"; - import { classifyClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { linuxHopperInstallDisclosure, @@ -7,6 +5,7 @@ import { } from "./LinuxHopper.js"; import type { SkillDestination } from "./SetupSkill.js"; import { macHopperInstallDisclosure } from "./MacHopper.js"; +import { macHopperUserApplicationPath } from "./MacHopperPaths.js"; import type { ClientConfigurationInspection, SetupAction, @@ -287,10 +286,7 @@ const setupPlan = (input: { label: "Hopper deep-analysis provider", target: input.platform === "darwin" - ? join( - input.homeDirectory, - "Applications/Hopper Disassembler.app", - ) + ? macHopperUserApplicationPath(input.homeDirectory) : "system package manager", detail: input.platform === "linux" diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 2a2ebef9d5..77a448b4a3 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -272,6 +272,147 @@ describe("unavailable inventoried native add-ons", () => { }); }); +describe("unavailable package metadata", () => { + it("blocks directory fallback until same-container manifests are readable", () => { + const manifest = { + path: "app/relative/package.json", + container_sha256: file("app/main.cjs", "root").container_sha256, + sha256: null, + bytes: 28, + inventory_artifact_id: null, + inventory_occurrence_id: "missing-relative-manifest", + kind: "package-json" as const, + hash_status: "unavailable" as const, + limitations: ["unpacked manifest companion is missing"], + }; + const files = fileMap([ + file("app/main.cjs", "root"), + file("app/main.mjs", "root"), + file("app/relative/index.js", "root"), + ]); + const requestedPaths: string[] = []; + const lookup = { + get: (path: string) => { + requestedPaths.push(path); + return path === manifest.path ? manifest : undefined; + }, + hasDirectory: () => false, + }; + const relativeRequire = resolve({ + declaredPath: "./relative", + sourcePath: "app/main.cjs", + context: "module-specifier", + moduleKind: "require", + files, + unanalyzedFiles: lookup, + }); + expect(requestedPaths).toContain(manifest.path); + expect(relativeRequire).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + limitations: ["unpacked manifest companion is missing"], + }); + expect( + resolve({ + declaredPath: "./relative", + sourcePath: "app/main.mjs", + context: "module-specifier", + moduleKind: "import", + files, + unanalyzedFiles: lookup, + }), + ).toMatchObject({ resolution_status: "not-found", resolved_path: null }); + + const restoredFiles = fileMap([ + ...files.values(), + file("app/relative/package.json", "root", '{"main":"public.js"}'), + file("app/relative/public.js", "root"), + ]); + expect( + resolve({ + declaredPath: "./relative", + sourcePath: "app/main.cjs", + context: "module-specifier", + moduleKind: "require", + files: restoredFiles, + unanalyzedFiles: lookup, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/relative/public.js", + }); + }); + + it("keeps bare package exports unknown while its manifest is unavailable", () => { + const containerSha256 = file("app/main.mjs", "root").container_sha256; + const manifest = { + path: "app/node_modules/fixture/package.json", + container_sha256: containerSha256, + sha256: null, + bytes: 47, + inventory_artifact_id: null, + kind: "package-json" as const, + hash_status: "unavailable" as const, + limitations: ["unpacked exports manifest is missing"], + }; + const files = fileMap([ + file("app/main.cjs", "root"), + file("app/main.mjs", "root"), + file("app/node_modules/fixture/index.js", "root"), + file("app/node_modules/fixture/import.js", "root"), + file("app/node_modules/fixture/require.js", "root"), + ]); + const lookup = { + get: (path: string) => (path === manifest.path ? manifest : undefined), + hasDirectory: () => false, + }; + for (const [sourcePath, moduleKind] of [ + ["app/main.cjs", "require"], + ["app/main.mjs", "import"], + ] as const) + expect( + resolve({ + declaredPath: "fixture", + sourcePath, + context: "module-specifier", + moduleKind, + files, + unanalyzedFiles: lookup, + }), + ).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + limitations: ["unpacked exports manifest is missing"], + }); + + const restoredFiles = fileMap([ + ...files.values(), + file( + manifest.path, + "root", + '{"exports":{"import":"./import.js","require":"./require.js"}}', + ), + ]); + for (const [sourcePath, moduleKind, target] of [ + ["app/main.cjs", "require", "app/node_modules/fixture/require.js"], + ["app/main.mjs", "import", "app/node_modules/fixture/import.js"], + ] as const) + expect( + resolve({ + declaredPath: "fixture", + sourcePath, + context: "module-specifier", + moduleKind, + files: restoredFiles, + unanalyzedFiles: lookup, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: target, + }); + }); +}); + describe("bare package root file lookup", () => { it("uses loader-specific bare package roots when only a sibling .js file exists", () => { const absentDirectory = fileMap([ diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index b056ce959f..7a3e4c89e0 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -1144,10 +1144,21 @@ const readPackageMetadata = ( const path = posix.join(directory, "package.json"); const file = input.files.get(path); const source = input.files.get(input.sourcePath); - if ( - file === undefined || - (source !== undefined && file.container_sha256 !== source.container_sha256) - ) + if (file === undefined) { + const unavailable = input.unanalyzedFiles?.get(path); + if ( + source !== undefined && + unavailable?.container_sha256 === source.container_sha256 + ) + return { + kind: "unavailable", + limitation: + unavailable.limitations?.[0] ?? + `Directory package metadata ${path} was inventoried but its bytes are unavailable.`, + }; + return { kind: "absent" }; + } + if (source !== undefined && file.container_sha256 !== source.container_sha256) return { kind: "absent" }; if (!file.text.included) return { diff --git a/src/cli/adbCommands.ts b/src/cli/adbCommands.ts index 677cf8f91a..1a8180ee92 100644 --- a/src/cli/adbCommands.ts +++ b/src/cli/adbCommands.ts @@ -284,7 +284,7 @@ export const registerAdbCommands = ( }); cli.command(CLI_COMMANDS.installAdbPackage, { description: - "Install one local APK; --replace re-installs keeping the app's data", + "Install one local APK; --replace requests reinstall mode to keep existing app data", args: z.object({ serial, "apk-path": z.string().min(1).describe("Local APK file to install"), @@ -293,7 +293,9 @@ export const registerAdbCommands = ( replace: z .boolean() .default(false) - .describe("Replace an existing install and keep its data"), + .describe( + "Request reinstall mode and keep existing app data if present", + ), }), run: ({ args, options }) => execute(CLI_COMMANDS.installAdbPackage, "install_adb_package", { diff --git a/src/config.test.ts b/src/config.test.ts index 0230d552b7..5fc1712560 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -368,3 +368,29 @@ describe("default Linux Hopper launcher", () => { ).toBe(`${home}/.local/share/rea/hopper/bin/Hopper`); }); }); + +describe("default macOS Hopper launcher", () => { + it("selects the system path before the installer user path", () => { + const home = "/fixture/home"; + const system = + "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper"; + const user = + "/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper"; + const executable = (path: string): boolean => + path === system || path === user; + + expect(defaultHopperLauncherPath("darwin", home, executable)).toBe(system); + expect( + defaultHopperLauncherPath("darwin", home, (path) => path === user), + ).toBe(user); + }); + + it("keeps an explicit launcher path unchanged", () => { + const configured = "/custom/../Hopper launcher"; + const parsed = parseConfig({ HOPPER_LAUNCHER_PATH: configured }); + expect(parsed).toMatchObject({ + ok: true, + value: { hopperLauncherPath: configured }, + }); + }); +}); diff --git a/src/config/parseConfig.ts b/src/config/parseConfig.ts index 415af6b3df..1dbeda7578 100644 --- a/src/config/parseConfig.ts +++ b/src/config/parseConfig.ts @@ -9,9 +9,10 @@ import { linuxHopperLauncherCandidates, REA_MANAGED_LINUX_HOPPER_LAUNCHER, } from "../application/LinuxHopper.js"; - -const DEFAULT_HOPPER_LAUNCHER_PATH = - "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper"; +import { + MAC_HOPPER_SYSTEM_LAUNCHER, + macHopperLauncherCandidates, +} from "../application/MacHopperPaths.js"; const executableAvailable = (path: string): boolean => { try { accessSync(path, constants.X_OK); @@ -26,7 +27,12 @@ export const defaultHopperLauncherPath = ( homeDirectory: string, executable: (path: string) => boolean = executableAvailable, ): string => { - if (platform !== "linux") return DEFAULT_HOPPER_LAUNCHER_PATH; + if (platform === "darwin") { + for (const candidate of macHopperLauncherCandidates(homeDirectory)) + if (executable(candidate)) return candidate; + return MAC_HOPPER_SYSTEM_LAUNCHER; + } + if (platform !== "linux") return MAC_HOPPER_SYSTEM_LAUNCHER; return ( linuxHopperLauncherCandidates(homeDirectory).find(executable) ?? REA_MANAGED_LINUX_HOPPER_LAUNCHER diff --git a/src/contracts/adb/adbToolContracts.ts b/src/contracts/adb/adbToolContracts.ts index dd5859617a..df3d3e897f 100644 --- a/src/contracts/adb/adbToolContracts.ts +++ b/src/contracts/adb/adbToolContracts.ts @@ -288,7 +288,7 @@ export const ADB_TOOL_CONTRACTS = [ name: "install_adb_package", ...toolContractMetadata("install_adb_package"), description: - "Install one local APK on the device (`adb install [-r]`), recording the APK's host digest and byte size. A device-mutating tool: `replace` re-installs over an existing package while keeping its data; the device's own Success/Failure verdict is preserved with its reason.", + "Install one local APK on the device (`adb install [-r]`), recording the APK's host digest and byte size. A device-mutating tool: `replace_requested` records whether `-r` was selected; `replaced` is null because install success does not establish prior package state. The device's own Success/Failure verdict is preserved with its reason.", kind: "adb-provider", inputSchema: adbInputSchemas.install_adb_package, outputSchema: evidenceResultOf(adbResultSchemas.install_adb_package), @@ -298,7 +298,7 @@ export const ADB_TOOL_CONTRACTS = [ input: { serial: "emulator-5554", apk_path: "/tmp/app.apk" }, }, { - title: "Replace an existing install keeping data", + title: "Request reinstall mode to keep existing app data", input: { serial: "emulator-5554", apk_path: "/tmp/app.apk", diff --git a/src/domain/adb/adbDeviceAnalysis.ts b/src/domain/adb/adbDeviceAnalysis.ts index c524ebd02e..ca6cf78e58 100644 --- a/src/domain/adb/adbDeviceAnalysis.ts +++ b/src/domain/adb/adbDeviceAnalysis.ts @@ -234,7 +234,7 @@ export const adbInputSchemas = { .boolean() .default(false) .describe( - "Replace an existing installation and keep its data (adb install -r)", + "Request replacement mode and keep existing app data if present (adb install -r); does not establish whether the app was already installed", ), }), uninstall_adb_package: z.strictObject({ @@ -685,7 +685,8 @@ export const adbResultSchemas = { apk_path: z.string().min(1), bytes: z.number().int().nonnegative(), sha256: z.string().regex(/^[a-f0-9]{64}$/u), - replaced: z.boolean(), + replace_requested: z.boolean(), + replaced: z.boolean().nullable(), }), uninstall_adb_package: z.strictObject({ client, diff --git a/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts index 831c9ebc41..9fca4e7392 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts @@ -62,6 +62,22 @@ describe("TypeScript import-equals links", () => { }); describe("CommonJS wrapper parameter redeclarations", () => { + it("keeps a top-level wrapper require redeclaration", () => { + const path = "app/main.cjs"; + const parsed = parseJavaScriptSource( + 'var require; require("./wrapper.cjs");', + path, + ); + expect(parsed).not.toBeNull(); + const ir = analyzeParsedJavaScriptSemantics( + parsed!, + moduleResolutionModeForJavaScriptReference(path, "static-import"), + ); + expect(ir.moduleLinks).toContainEqual( + expect.objectContaining({ kind: "require", specifier: "./wrapper.cjs" }), + ); + }); + it("retains ambient exports and require through uninitialized top-level vars", () => { const path = "app/main.cjs"; const parsed = parseJavaScriptSource( diff --git a/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts b/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts index f22aa55f2d..8881138662 100644 --- a/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts @@ -78,12 +78,12 @@ it("does not resolve a parameter named require as a module dependency", () => { ); }); -it("keeps a top-level wrapper require redeclaration", () => { +it("keeps a formatless top-level require redeclaration unresolved", () => { const analysis = analyzeJavaScriptStaticSource( `var require; require("./wrapper.cjs");`, ); expect(analysis.references).toContainEqual( - expect.objectContaining({ specifier: "./wrapper.cjs" }), + expect.objectContaining({ kind: "require", specifier: null }), ); }); diff --git a/tests/boundary/cli/adbCli.test.ts b/tests/boundary/cli/adbCli.test.ts index 71309e8787..f0f1d3f918 100644 --- a/tests/boundary/cli/adbCli.test.ts +++ b/tests/boundary/cli/adbCli.test.ts @@ -1,6 +1,8 @@ -import { chmod, lstat, readFile } from "node:fs/promises"; +import { chmod, lstat, readFile, unlink } from "node:fs/promises"; import { expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; import { expectConfigurationRefusal, serveCli as serve, @@ -42,6 +44,107 @@ it("validates device serials before contacting any device", async () => { }); }); +it.skipIf(process.platform === "win32")( + "preserves requested reinstall mode without claiming prior package state", + async () => { + const workspace = await createTestWorkspace("rea-adb-install-state-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const apk = await workspace.write("app.apk", "apk"); + const adb = await workspace.write( + "adb", + `#!/bin/sh +root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +printf '%s\\n' "$*" >> "$root/calls" +if [ "$1" = version ]; then + printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-cli\\nInstalled as %s\\n' "$0" + exit 0 +fi +if [ "$1" = -s ] && [ "$3" = install ]; then + if [ -f "$root/installed" ]; then + if [ "$4" != -r ]; then + printf 'Failure [INSTALL_FAILED_ALREADY_EXISTS]\\n' + exit 0 + fi + printf 'replacement\\n' >> "$root/events" + else + printf 'fresh-install\\n' >> "$root/events" + fi + : > "$root/installed" + printf 'Performing Streamed Install\\nSuccess\\n' + exit 0 +fi +exit 1 +`, + ); + await chmod(adb, 0o755); + + const installEnvelope = z.object({ + ok: z.literal(true), + data: toolContract("install_adb_package").outputSchema, + }); + const install = async (replace: boolean) => { + const result = await serve( + [ + "install-adb-package", + "emulator-5554", + apk, + ...(replace ? ["--replace"] : []), + "--format", + "json", + "--full-output", + ], + { REA_ADB_PATH: adb }, + ); + expect(result.exitCode).toBe(0); + return installEnvelope.parse(JSON.parse(result.stdout)); + }; + + const freshReplace = await install(true); + expect(freshReplace).toMatchObject({ + ok: true, + data: { + parameters: { replace: true }, + normalized_result: { replace_requested: true, replaced: null }, + confidence: "observed", + }, + }); + expect(freshReplace.data.limitations.join("\n")).toContain( + "Whether an existing package was replaced is unknown", + ); + + const existingReplace = await install(true); + expect(existingReplace).toMatchObject({ + ok: true, + data: { + parameters: { replace: true }, + normalized_result: { replace_requested: true, replaced: null }, + }, + }); + + await unlink(workspace.path("installed")); + const freshDefault = await install(false); + expect(freshDefault).toMatchObject({ + ok: true, + data: { + parameters: { replace: false }, + normalized_result: { replace_requested: false, replaced: null }, + }, + }); + expect(await readFile(workspace.path("events"), "utf8")).toBe( + "fresh-install\nreplacement\nfresh-install\n", + ); + const calls = await readFile(workspace.path("calls"), "utf8"); + expect(calls.split("\n").filter((line) => line !== "")).toEqual([ + "version", + `-s emulator-5554 install -r ${apk}`, + "version", + `-s emulator-5554 install -r ${apk}`, + "version", + `-s emulator-5554 install ${apk}`, + ]); + }, +); + it("requires the output directory option on pull", async () => { const result = await serve([ "pull-adb-package", diff --git a/tests/boundary/cli/doctorHopperDiscovery.test.ts b/tests/boundary/cli/doctorHopperDiscovery.test.ts new file mode 100644 index 0000000000..76d8be31a1 --- /dev/null +++ b/tests/boundary/cli/doctorHopperDiscovery.test.ts @@ -0,0 +1,74 @@ +import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { delimiter, join } from "node:path"; + +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.skipIf(process.platform === "win32")( + "preserves a configured Hopper launcher through public CLI doctor without launching it", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-doctor-hopper-home-"); + const launcher = `${home}/bin/../bin/hopper`; + const launchMarker = join(home, "hopper-was-launched"); + await mkdir(join(home, "bin"), { recursive: true }); + await writeFile( + launcher, + '#!/bin/sh\nprintf launched > "$REA_HOPPER_TEST_MARKER"\nexit 99\n', + ); + await chmod(launcher, 0o700); + const loaderMarker = join(home, "loader-checked-launcher"); + if (process.platform === "linux") { + // A shell fixture needs controlled loader diagnostics on Linux. This + // checks discovery and path preservation, not actual Hopper compatibility. + const loader = join(home, "bin", "ldd"); + await writeFile( + loader, + [ + "#!/bin/sh", + 'test "$#" -eq 1 || exit 87', + 'test "$1" = "$HOPPER_LAUNCHER_PATH" || exit 88', + 'printf "%s" "$1" > "$REA_HOPPER_LDD_TEST_MARKER"', + 'printf "%s\\n" "libc.so.6 => /fixture/libc.so.6 (0x1)"', + "", + ].join("\n"), + ); + await chmod(loader, 0o700); + } + + const result = await cli.run({ + arguments: ["doctor", "--provider", "hopper", "--json"], + cwd: home, + environment: { + ...(process.platform === "linux" + ? { + PATH: [join(home, "bin"), process.env.PATH ?? ""].join(delimiter), + REA_HOPPER_LDD_TEST_MARKER: loaderMarker, + } + : {}), + HOME: home, + USERPROFILE: home, + HOPPER_LAUNCHER_PATH: launcher, + REA_HOPPER_TEST_MARKER: launchMarker, + }, + timeoutMs: 20_000, + }); + + expect(result.json).toMatchObject({ + hopperPath: launcher, + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "hopper", + ok: true, + detail: launcher, + }), + ]), + }); + await expect(access(launchMarker)).rejects.toMatchObject({ + code: "ENOENT", + }); + if (process.platform === "linux") + expect(await readFile(loaderMarker, "utf8")).toBe(launcher); + }, +); diff --git a/tests/boundary/cli/javascriptSelfImportRelationships.test.ts b/tests/boundary/cli/javascriptSelfImportRelationships.test.ts index 27c4a9ed4d..97d542fdd8 100644 --- a/tests/boundary/cli/javascriptSelfImportRelationships.test.ts +++ b/tests/boundary/cli/javascriptSelfImportRelationships.test.ts @@ -1,4 +1,5 @@ -import { rm } from "node:fs/promises"; +import { createPackageWithOptions } from "@electron/asar"; +import { rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; @@ -15,6 +16,92 @@ import { writeFixtureFiles, } from "../../support/javascriptApplicationFixture.js"; +cliTest( + "keeps missing same-container package manifests unavailable until restored", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-missing-package-manifests-", + ); + const sourceRoot = join(root, "source"); + const archive = join(root, "app.asar"); + const relativeManifest = '{"main":"public.js"}'; + const exportsManifest = + '{"exports":{"import":"./import.js","require":"./require.js"}}'; + await writeFixtureFiles(sourceRoot, { + "main.cjs": 'require("./relative");\nrequire("fixture");\n', + "main.mjs": 'import "fixture";\n', + "relative/package.json": relativeManifest, + "relative/index.js": "export const fallback = true;\n", + "relative/public.js": "export const publicEntry = true;\n", + "node_modules/fixture/package.json": exportsManifest, + "node_modules/fixture/index.js": "export const fallback = true;\n", + "node_modules/fixture/import.js": "export const imported = true;\n", + "node_modules/fixture/require.js": "module.exports = true;\n", + }); + await createPackageWithOptions(sourceRoot, archive, { + unpack: "package.json", + }); + const unpacked = `${archive}.unpacked`; + const relativeManifestPath = join(unpacked, "relative/package.json"); + const exportsManifestPath = join( + unpacked, + "node_modules/fixture/package.json", + ); + await Promise.all([rm(relativeManifestPath), rm(exportsManifestPath)]); + + const analyze = async () => { + const output = await cli.run({ + arguments: ["analyze-javascript-application", archive, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + return javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + }; + const unavailable = await analyze(); + const cjs = findSourceModule(unavailable.graph, "main.cjs"); + const mjs = findSourceModule(unavailable.graph, "main.mjs"); + for (const [source, specifier] of [ + [cjs, "./relative"], + [cjs, "fixture"], + [mjs, "fixture"], + ] as const) + expect( + unavailable.graph.edges.find( + (edge) => + edge.source_node_id === source?.node_id && + edge.properties.specifier === specifier, + )?.properties, + ).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + }); + expect(unavailable.graph.coverage.status).toBe("partial"); + + await Promise.all([ + writeFile(relativeManifestPath, relativeManifest), + writeFile(exportsManifestPath, exportsManifest), + ]); + const restored = await analyze(); + for (const [sourcePath, specifier, target] of [ + ["main.cjs", "./relative", "relative/public.js"], + ["main.cjs", "fixture", "node_modules/fixture/require.js"], + ["main.mjs", "fixture", "node_modules/fixture/import.js"], + ] as const) + expect( + restored.graph.edges.find( + (edge) => + edge.source_node_id === + findSourceModule(restored.graph, sourcePath)?.node_id && + edge.properties.specifier === specifier, + )?.properties, + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: target, + }); + }, +); + cliTest( "resolves ESM specifiers containing whitespace using URL text semantics", async ({ cli }) => { diff --git a/tests/boundary/mcp/adbMcp.test.ts b/tests/boundary/mcp/adbMcp.test.ts index 07fe6e9c46..656855dd0e 100644 --- a/tests/boundary/mcp/adbMcp.test.ts +++ b/tests/boundary/mcp/adbMcp.test.ts @@ -233,7 +233,8 @@ const results: Readonly> = { apk_path: "/tmp/app.apk", bytes: 2048, sha256: "a".repeat(64), - replaced: false, + replace_requested: false, + replaced: null, }, uninstall_adb_package: { client: clientIdentity,