Integrate the separately reviewed main test-fixture prerequisites
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
commit
a37755d57c
24 files changed
+696
-45
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<DoctorCheck> =>
|
||||
});
|
||||
|
||||
const manualHopperPaths = async (home: string): Promise<readonly string[]> => {
|
||||
const paths: string[] = [];
|
||||
for (const root of ["/Applications", join(home, "Applications")]) {
|
||||
const roots = macHopperApplicationRoots(home);
|
||||
const bundleNamesByRoot = new Map<string, readonly string[]>();
|
||||
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[] => [
|
||||
|
||||
@@ -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<string | undefined> => {
|
||||
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];
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, readonly string[]>,
|
||||
): 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<string> {
|
||||
yield* macHopperCanonicalLauncherCandidates(homeDirectory);
|
||||
for (const root of macHopperApplicationRoots(homeDirectory))
|
||||
yield* macHopperRenamedLauncherCandidates(
|
||||
homeDirectory,
|
||||
new Map([[root, localBundleNames(root)]]),
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -233,7 +233,8 @@ const results: Readonly<Record<string, unknown>> = {
|
||||
apk_path: "/tmp/app.apk",
|
||||
bytes: 2048,
|
||||
sha256: "a".repeat(64),
|
||||
replaced: false,
|
||||
replace_requested: false,
|
||||
replaced: null,
|
||||
},
|
||||
uninstall_adb_package: {
|
||||
client: clientIdentity,
|
||||
|
||||
Reference in new issue
Block a user