Integrate the separately reviewed main test-fixture prerequisites

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
N0zoM1z0andKaoru0822 committed 2026-10-14 09:44:28 +00:00
commit a37755d57c
24 files changed
+696 -45

No files matched your search

+6 -1
View File
@@ -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,
+7 -2
View File
@@ -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
+9 -3
View File
@@ -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
+3 -1
View File
@@ -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",
],
},
),
+22
View File
@@ -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 -7
View File
@@ -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[] => [
+12 -4
View File
@@ -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];
+2 -2
View File
@@ -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),
};
};
+56
View File
@@ -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 });
}
});
});
+76
View File
@@ -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)]]),
);
}
+2 -6
View File
@@ -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 {
+4 -2
View File
@@ -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", {
+26
View File
@@ -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 },
});
});
});
+10 -4
View File
@@ -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
+2 -2
View File
@@ -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",
+3 -2
View File
@@ -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 }),
);
});
+104 -1
View File
@@ -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 }) => {
+2 -1
View File
@@ -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,