fix(config): preserve captured environments across provider launches

This commit is contained in:
morluto committed 2026-10-26 17:39:23 +08:00
1 parent ccdeda7a27
commit 3864c7d667
4 files changed
+141 -50

No files matched your search

+1 -10
View File
@@ -56,7 +56,6 @@ import {
import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { WindowsBatchCommandError } from "../process/WindowsBatchCommand.js"; import { WindowsBatchCommandError } from "../process/WindowsBatchCommand.js";
import { import {
APKTOOL_COMMAND_ENV,
ApktoolConfigurationFailure, ApktoolConfigurationFailure,
apktoolConfigurationAvailability, apktoolConfigurationAvailability,
apktoolInvocation, apktoolInvocation,
@@ -291,15 +290,7 @@ export class ApktoolProvider {
} }
private operationEnvironment(): Readonly<NodeJS.ProcessEnv> { private operationEnvironment(): Readonly<NodeJS.ProcessEnv> {
const ambient = snapshotEnvironment(process.env); return this.selectedEnvironment ?? 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],
});
} }
/** Probe the selected apktool launcher; decodes nothing. */ /** Probe the selected apktool launcher; decodes nothing. */
+8 -2
View File
@@ -1,3 +1,4 @@
import { snapshotEnvironment } from "../../process/snapshotEnvironment.js";
import { isAbsolute } from "node:path"; import { isAbsolute } from "node:path";
import { z } from "zod"; import { z } from "zod";
import type { import type {
@@ -78,10 +79,14 @@ export class NativeModuleResolver implements WebModuleResolutionPort {
#closePromise: Promise<void> | undefined; #closePromise: Promise<void> | undefined;
#pendingCleanup: AnalysisCleanupLease | undefined; #pendingCleanup: AnalysisCleanupLease | undefined;
readonly environment: Readonly<Record<string, string | undefined>>;
constructor( constructor(
readonly environment: Readonly<Record<string, string | undefined>>, environment: Readonly<Record<string, string | undefined>>,
readonly launcher: ModuleResolverLauncher = launch, readonly launcher: ModuleResolverLauncher = launch,
) {} ) {
this.environment = snapshotEnvironment(environment);
}
resolve( resolve(
input: Parameters<WebModuleResolutionPort["resolve"]>[0], input: Parameters<WebModuleResolutionPort["resolve"]>[0],
@@ -180,6 +185,7 @@ export class NativeModuleResolver implements WebModuleResolutionPort {
const browser = await observe(() => const browser = await observe(() =>
this.launcher({ this.launcher({
executablePath: executable, executablePath: executable,
env: this.environment,
headless: true, headless: true,
timeout: Math.max(1, deadline.remainingMs()), timeout: Math.max(1, deadline.remainingMs()),
handleSIGINT: false, handleSIGINT: false,
+69 -38
View File
@@ -1,58 +1,89 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, expect, it, onTestFinished, vi } from "vitest"; import { afterEach, expect, it, onTestFinished, vi } from "vitest";
import { createCli } from "../../src/cli.js";
import { ApktoolProvider } from "../../src/apktool/ApktoolProvider.js"; import { ApktoolProvider } from "../../src/apktool/ApktoolProvider.js";
import { runOwnedCommand } from "../../src/process/OwnedCommand.js"; import { ApktoolResourceAnalysisService } from "../../src/application/apktool/ApktoolResourceAnalysisService.js";
import type { ProviderProcessSnapshot } from "../../src/process/ProviderProcess.js"; import { parseEvidence } from "../../src/domain/evidence.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
vi.mock("../../src/process/OwnedCommand.js", async (importOriginal) => ({ const initialExitCode = process.exitCode;
...(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,
),
}));
afterEach(() => { afterEach(() => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
vi.mocked(runOwnedCommand).mockClear(); process.exitCode = initialExitCode;
}); });
const lastSpawn = () => vi.mocked(runOwnedCommand).mock.lastCall?.[0];
it.skipIf(process.platform === "win32")( // These executable probes validate the real CLI and process environment boundary;
"keeps an explicit environment's PATH choice and samples ambient inheritance per operation", // they do not establish native Apktool decoding behavior.
async () => { it.skipIf(process.platform === "win32").each([
vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/initial-apktool"); { name: "missing", selected: {}, expected: "absent" },
vi.stubEnv("REA_APKTOOL_TEST_INHERITED_VALUE", "initial"); {
const provider = new ApktoolProvider({ environment: {} }); name: "unset",
onTestFinished(() => provider.close()); selected: { REA_APKTOOL_ENV_PROFILE: undefined },
vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/changed-apktool"); expected: "absent",
vi.stubEnv("REA_APKTOOL_TEST_INHERITED_VALUE", "changed"); },
await provider.inspectAvailability(); {
expect(lastSpawn()?.command).toBe("apktool"); name: "empty",
expect(lastSpawn()?.hostEnvironment).toMatchObject({ selected: { REA_APKTOOL_ENV_PROFILE: "" },
REA_APKTOOL_COMMAND: undefined, expected: "empty",
REA_APKTOOL_TEST_INHERITED_VALUE: "changed", },
{
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); const evidence = parseEvidence(JSON.parse(stdout));
await provider.inspectAvailability(); expect(evidence.normalized_result).toMatchObject({
expect( client: { apktool_version: `2.11.1+${expected}` },
lastSpawn()?.hostEnvironment?.REA_APKTOOL_TEST_INHERITED_VALUE, });
).toBeUndefined(); expect(evidence.environment).toBeNull();
}, },
); );
it.skipIf(process.platform === "win32")( it.skipIf(process.platform === "win32")(
"inherits the current default launcher when no environment was selected", "inherits the current default launcher when no environment was selected",
async () => { 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"); vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/initial-apktool");
const provider = new ApktoolProvider(); const provider = new ApktoolProvider();
onTestFinished(() => provider.close()); onTestFinished(() => provider.close());
vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/later-apktool"); vi.stubEnv("REA_APKTOOL_COMMAND", command);
await provider.inspectAvailability(); const result = await new ApktoolResourceAnalysisService(provider).execute(
expect(lastSpawn()?.command).toBe("/ambient/later-apktool"); "inspect_apktool_client",
{},
);
if (!result.ok) throw result.error;
expect(result.value.normalized_result).toMatchObject({
client: { command, apktool_version: "2.11.1+later" },
});
}, },
); );
@@ -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();
},
);