diff --git a/src/apktool/ApktoolProvider.ts b/src/apktool/ApktoolProvider.ts index 9e46106f32..265288c9f3 100644 --- a/src/apktool/ApktoolProvider.ts +++ b/src/apktool/ApktoolProvider.ts @@ -56,7 +56,6 @@ import { import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { WindowsBatchCommandError } from "../process/WindowsBatchCommand.js"; import { - APKTOOL_COMMAND_ENV, ApktoolConfigurationFailure, apktoolConfigurationAvailability, apktoolInvocation, @@ -291,15 +290,7 @@ export class ApktoolProvider { } private operationEnvironment(): Readonly { - const ambient = snapshotEnvironment(process.env); - if (this.selectedEnvironment === undefined) return ambient; - // Child inheritance is sampled per operation. An explicit map keeps its - // launcher choice authoritative, including an omitted command selecting PATH. - return snapshotEnvironment({ - ...ambient, - ...this.selectedEnvironment, - [APKTOOL_COMMAND_ENV]: this.selectedEnvironment[APKTOOL_COMMAND_ENV], - }); + return this.selectedEnvironment ?? snapshotEnvironment(process.env); } /** Probe the selected apktool launcher; decodes nothing. */ diff --git a/src/browser/modules/NativeModuleResolver.ts b/src/browser/modules/NativeModuleResolver.ts index 6247003502..9bf0ba59dc 100644 --- a/src/browser/modules/NativeModuleResolver.ts +++ b/src/browser/modules/NativeModuleResolver.ts @@ -1,3 +1,4 @@ +import { snapshotEnvironment } from "../../process/snapshotEnvironment.js"; import { isAbsolute } from "node:path"; import { z } from "zod"; import type { @@ -78,10 +79,14 @@ export class NativeModuleResolver implements WebModuleResolutionPort { #closePromise: Promise | undefined; #pendingCleanup: AnalysisCleanupLease | undefined; + readonly environment: Readonly>; + constructor( - readonly environment: Readonly>, + environment: Readonly>, readonly launcher: ModuleResolverLauncher = launch, - ) {} + ) { + this.environment = snapshotEnvironment(environment); + } resolve( input: Parameters[0], @@ -180,6 +185,7 @@ export class NativeModuleResolver implements WebModuleResolutionPort { const browser = await observe(() => this.launcher({ executablePath: executable, + env: this.environment, headless: true, timeout: Math.max(1, deadline.remainingMs()), handleSIGINT: false, diff --git a/tests/process-global/apktoolEnvironment.test.ts b/tests/process-global/apktoolEnvironment.test.ts index 876b05aa5a..294155f207 100644 --- a/tests/process-global/apktoolEnvironment.test.ts +++ b/tests/process-global/apktoolEnvironment.test.ts @@ -1,58 +1,89 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { afterEach, expect, it, onTestFinished, vi } from "vitest"; +import { createCli } from "../../src/cli.js"; import { ApktoolProvider } from "../../src/apktool/ApktoolProvider.js"; -import { runOwnedCommand } from "../../src/process/OwnedCommand.js"; -import type { ProviderProcessSnapshot } from "../../src/process/ProviderProcess.js"; +import { ApktoolResourceAnalysisService } from "../../src/application/apktool/ApktoolResourceAnalysisService.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; -vi.mock("../../src/process/OwnedCommand.js", async (importOriginal) => ({ - ...(await importOriginal< - typeof import("../../src/process/OwnedCommand.js") - >()), - runOwnedCommand: vi.fn( - async () => - ({ - stdout: { text: "2.7.0-stub\n" }, - stderr: { text: "" }, - }) as unknown as ProviderProcessSnapshot, - ), -})); +const initialExitCode = process.exitCode; afterEach(() => { vi.unstubAllEnvs(); - vi.mocked(runOwnedCommand).mockClear(); + process.exitCode = initialExitCode; }); -const lastSpawn = () => vi.mocked(runOwnedCommand).mock.lastCall?.[0]; -it.skipIf(process.platform === "win32")( - "keeps an explicit environment's PATH choice and samples ambient inheritance per operation", - async () => { - vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/initial-apktool"); - vi.stubEnv("REA_APKTOOL_TEST_INHERITED_VALUE", "initial"); - const provider = new ApktoolProvider({ environment: {} }); - onTestFinished(() => provider.close()); - vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/changed-apktool"); - vi.stubEnv("REA_APKTOOL_TEST_INHERITED_VALUE", "changed"); - await provider.inspectAvailability(); - expect(lastSpawn()?.command).toBe("apktool"); - expect(lastSpawn()?.hostEnvironment).toMatchObject({ - REA_APKTOOL_COMMAND: undefined, - REA_APKTOOL_TEST_INHERITED_VALUE: "changed", +// These executable probes validate the real CLI and process environment boundary; +// they do not establish native Apktool decoding behavior. +it.skipIf(process.platform === "win32").each([ + { name: "missing", selected: {}, expected: "absent" }, + { + name: "unset", + selected: { REA_APKTOOL_ENV_PROFILE: undefined }, + expected: "absent", + }, + { + name: "empty", + selected: { REA_APKTOOL_ENV_PROFILE: "" }, + expected: "empty", + }, + { + name: "selected", + selected: { REA_APKTOOL_ENV_PROFILE: "selected-profile" }, + expected: "selected-profile", + }, +])( + "preserves $name Apktool settings after the ambient environment changes", + async ({ selected, expected }) => { + const root = await createTestTempDirectory( + "rea-apktool-selected-environment-", + ); + const command = join(root, "apktool"); + await writeFile( + command, + '#!/bin/sh\nprofile=${REA_APKTOOL_ENV_PROFILE-absent}\n[ -n "$profile" ] || profile=empty\nprintf "2.11.1+%s\\n" "$profile"\n', + { mode: 0o755 }, + ); + const cli = createCli({ REA_APKTOOL_COMMAND: command, ...selected }); + vi.stubEnv("REA_APKTOOL_ENV_PROFILE", "late-ambient-profile"); + let stdout = ""; + await cli.serve(["inspect-apktool-client", "--json"], { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: () => undefined, }); - vi.stubEnv("REA_APKTOOL_TEST_INHERITED_VALUE", undefined); - await provider.inspectAvailability(); - expect( - lastSpawn()?.hostEnvironment?.REA_APKTOOL_TEST_INHERITED_VALUE, - ).toBeUndefined(); + const evidence = parseEvidence(JSON.parse(stdout)); + expect(evidence.normalized_result).toMatchObject({ + client: { apktool_version: `2.11.1+${expected}` }, + }); + expect(evidence.environment).toBeNull(); }, ); it.skipIf(process.platform === "win32")( "inherits the current default launcher when no environment was selected", async () => { + const root = await createTestTempDirectory( + "rea-apktool-default-environment-", + ); + const command = join(root, "apktool"); + await writeFile(command, '#!/bin/sh\nprintf "2.11.1+later\\n"\n', { + mode: 0o755, + }); vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/initial-apktool"); const provider = new ApktoolProvider(); onTestFinished(() => provider.close()); - vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/later-apktool"); - await provider.inspectAvailability(); - expect(lastSpawn()?.command).toBe("/ambient/later-apktool"); + vi.stubEnv("REA_APKTOOL_COMMAND", command); + const result = await new ApktoolResourceAnalysisService(provider).execute( + "inspect_apktool_client", + {}, + ); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + client: { command, apktool_version: "2.11.1+later" }, + }); }, ); diff --git a/tests/process-global/nativeModuleResolverEnvironment.test.ts b/tests/process-global/nativeModuleResolverEnvironment.test.ts new file mode 100644 index 0000000000..6a941bea21 --- /dev/null +++ b/tests/process-global/nativeModuleResolverEnvironment.test.ts @@ -0,0 +1,63 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, it, onTestFinished, vi } from "vitest"; + +import { NativeModuleResolver } from "../../src/browser/modules/NativeModuleResolver.js"; +import { observeProcessStartIdentity } from "../../src/process/ProcessOwnershipObservation.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +afterEach(() => vi.unstubAllEnvs()); + +// Playwright starts the actual selected executable, which records its environment +// and exits before the browser handshake. This proves launch inheritance only. +it.skipIf(process.platform === "win32").each([ + { name: "missing", selected: {}, expected: "absent" }, + { + name: "unset", + selected: { REA_MODULE_ENV_PROFILE: undefined }, + expected: "absent", + }, + { name: "empty", selected: { REA_MODULE_ENV_PROFILE: "" }, expected: "" }, + { + name: "selected", + selected: { REA_MODULE_ENV_PROFILE: "selected-profile" }, + expected: "selected-profile", + }, +])( + "preserves $name module-resolver settings in the real Playwright child", + async ({ selected, expected }) => { + const root = await createTestTempDirectory( + "rea-module-resolver-environment-", + ); + const command = join(root, "browser"), + receipt = join(root, "receipt"); + const quotedReceipt = `'${receipt.replaceAll("'", "'\\''")}'`; + await writeFile( + command, + '#!/bin/sh\nprintf "%s\\n%s" "$$" "${REA_MODULE_ENV_PROFILE-absent}" > ' + + quotedReceipt + + "\nexit 0\n", + { mode: 0o755 }, + ); + const environment = { REA_BROWSER_EXECUTABLE: command, ...selected }; + const resolver = new NativeModuleResolver(environment); + onTestFinished(() => resolver.close()); + // Constructor selection is immutable even when the original caller map changes. + Object.assign(environment, { + REA_MODULE_ENV_PROFILE: "later-caller-profile", + }); + vi.stubEnv("REA_MODULE_ENV_PROFILE", "late-ambient-profile"); + const result = await resolver.resolve({ + importerUrl: "https://app.test/main.js", + importMap: null, + specifiers: ["./dep.js"], + }); + expect(result.ok).toBe(false); + const observed = await readFile(receipt, "utf8"); + const separator = observed.indexOf("\n"); + expect(observed.slice(separator + 1)).toBe(expected); + const pid = Number(observed.slice(0, separator)); + expect(Number.isSafeInteger(pid) && pid > 0).toBe(true); + expect(await observeProcessStartIdentity(pid)).toBeUndefined(); + }, +);