fix(electron): attribute unresolved paths to selected inputs
Resolve each caller-selected Electron path once at the application boundary, collect field-specific failures with original causes, and pass canonical paths directly to the provider. Update direct provider consumers and route the real verifier through the shared application workflow. Validation: public CLI/MCP invalid-path acceptance, canonicalization boundary, provider tests, and cancellation/cleanup checks. Live Electron launch was not verified. This does not address the separate Playwright launch-rejection crash in #2269. Fixes #2272
This commit is contained in:
parent
049132ab43
commit
b7169c8b74
|
|
@ -4,7 +4,11 @@ import { join } from "node:path";
|
|||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { PlaywrightElectronActiveProvider } from "../dist/browser/PlaywrightElectronActiveProvider.js";
|
||||
import { electronActiveObservationInputSchema } from "../dist/domain/javascript/electronActiveObservation.js";
|
||||
import { captureElectronScenario } from "../dist/application/javascript/ElectronActiveObservationService.js";
|
||||
import {
|
||||
electronActiveObservationInputSchema,
|
||||
electronActiveObservationResultSchema,
|
||||
} from "../dist/domain/javascript/electronActiveObservation.js";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { verifyElectronToolContracts } from "./verify/electron/tool-contracts.mjs";
|
||||
|
||||
|
|
@ -50,61 +54,65 @@ const input = electronActiveObservationInputSchema.parse({
|
|||
],
|
||||
});
|
||||
const verifierRun = createVerifierRun();
|
||||
const result = await new PlaywrightElectronActiveProvider(process.env).capture(
|
||||
const result = await captureElectronScenario(
|
||||
new PlaywrightElectronActiveProvider(process.env),
|
||||
input,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
const capture = electronActiveObservationResultSchema.parse(
|
||||
result.value.normalized_result,
|
||||
);
|
||||
|
||||
const output = {
|
||||
verifier_run: await completeVerifierRun(verifierRun),
|
||||
electron: result.value.application,
|
||||
actions: result.value.actions,
|
||||
processes: result.value.processes,
|
||||
ipc: result.value.ipc,
|
||||
timeline: result.value.timeline,
|
||||
windows: result.value.windows,
|
||||
electron: capture.application,
|
||||
actions: capture.actions,
|
||||
processes: capture.processes,
|
||||
ipc: capture.ipc,
|
||||
timeline: capture.timeline,
|
||||
windows: capture.windows,
|
||||
verified:
|
||||
result.value.coverage.status === "partial_attach" &&
|
||||
result.value.actions.every(({ status }) => status === "completed") &&
|
||||
result.value.actions.some(
|
||||
capture.coverage.status === "partial_attach" &&
|
||||
capture.actions.every(({ status }) => status === "completed") &&
|
||||
capture.actions.some(
|
||||
({ kind, window_index }) =>
|
||||
kind === "renderer-reload" && window_index === 1,
|
||||
) &&
|
||||
result.value.actions.some(
|
||||
capture.actions.some(
|
||||
({ kind, window_index }) =>
|
||||
kind === "renderer-crash" && window_index === 1,
|
||||
) &&
|
||||
result.value.windows.length >= 2 &&
|
||||
result.value.ipc.events.some(
|
||||
capture.windows.length >= 2 &&
|
||||
capture.ipc.events.some(
|
||||
({ kind, channel }) =>
|
||||
kind === "main-handler-invocation" && channel === "readiness:echo",
|
||||
) &&
|
||||
result.value.timeline.events.some(
|
||||
capture.timeline.events.some(
|
||||
({ kind, event, phase }) =>
|
||||
kind === "window-lifecycle" &&
|
||||
event === "created" &&
|
||||
phase === "completed",
|
||||
) &&
|
||||
result.value.timeline.events.some(
|
||||
capture.timeline.events.some(
|
||||
({ kind, event }) =>
|
||||
kind === "process-lifecycle" && event === "utility-process-fork",
|
||||
) &&
|
||||
result.value.timeline.events.some(
|
||||
capture.timeline.events.some(
|
||||
({ kind, event, phase }) =>
|
||||
kind === "shell-attempt" &&
|
||||
event === "openExternal" &&
|
||||
phase === "blocked",
|
||||
) &&
|
||||
result.value.timeline.events.some(
|
||||
capture.timeline.events.some(
|
||||
({ kind, event, phase }) =>
|
||||
kind === "preload" && event === "configured" && phase === "completed",
|
||||
) &&
|
||||
result.value.timeline.events.some(
|
||||
capture.timeline.events.some(
|
||||
({ kind, event }) =>
|
||||
kind === "protocol" &&
|
||||
(event === "open-url" || event === "second-instance"),
|
||||
) &&
|
||||
result.value.timeline.events.some(
|
||||
capture.timeline.events.some(
|
||||
({ kind, event }) =>
|
||||
kind === "process-lifecycle" && event === "child.spawn",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,18 +1,11 @@
|
|||
import { digestCanonicalValue } from "../../domain/canonicalDigest.js";
|
||||
import type { ProviderIdentity } from "../AnalysisProvider.js";
|
||||
import type { CanonicalElectronActiveObservationInput } from "./ElectronActiveObservationPort.js";
|
||||
import type { Evidence, EvidenceObservation } from "../../domain/evidence.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import type {
|
||||
ElectronActiveObservationInput,
|
||||
ElectronActiveObservationResult,
|
||||
} from "../../domain/javascript/electronActiveObservation.js";
|
||||
import type { ElectronActiveObservationResult } from "../../domain/javascript/electronActiveObservation.js";
|
||||
import { jsonObjectSchema, jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
|
||||
type CanonicalElectronActiveObservationInput =
|
||||
ElectronActiveObservationInput & {
|
||||
readonly application_root: string;
|
||||
};
|
||||
|
||||
/** Commit the selected Electron experiment and its observed result. */
|
||||
export const createElectronActiveEvidence = (
|
||||
input: CanonicalElectronActiveObservationInput,
|
||||
|
|
|
|||
|
|
@ -9,11 +9,15 @@ import type {
|
|||
} from "../../domain/javascript/electronActiveObservation.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
|
||||
/** Caller selections resolved once before entering the runtime adapter. */
|
||||
export type CanonicalElectronActiveObservationInput =
|
||||
ElectronActiveObservationInput & { readonly application_root: string };
|
||||
|
||||
/** Provider-neutral boundary for owned Electron runtime experiments. */
|
||||
export interface ElectronActiveObservationPort {
|
||||
identity(): ProviderIdentity;
|
||||
capture(
|
||||
input: ElectronActiveObservationInput,
|
||||
input: CanonicalElectronActiveObservationInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<ElectronActiveObservationResult, AnalysisError>>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,57 +2,80 @@ import { dirname } from "node:path";
|
|||
import { realpath } from "node:fs/promises";
|
||||
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { ElectronActiveObservationPort } from "./ElectronActiveObservationPort.js";
|
||||
import type {
|
||||
CanonicalElectronActiveObservationInput,
|
||||
ElectronActiveObservationPort,
|
||||
} from "./ElectronActiveObservationPort.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
type AnalysisInputIssue,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import type { ElectronActiveObservationInput } from "../../domain/javascript/electronActiveObservation.js";
|
||||
import { err, type Result } from "../../domain/result.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { createElectronActiveEvidence } from "./ElectronActiveEvidence.js";
|
||||
|
||||
const OPERATION = "capture_electron_scenario" as const;
|
||||
const PROVIDER_ID = "rea-playwright-electron-active";
|
||||
type CanonicalElectronActiveObservationInput =
|
||||
ElectronActiveObservationInput & {
|
||||
readonly application_root: string;
|
||||
};
|
||||
|
||||
const resolveInputPath = async (
|
||||
field: "executable_path" | "application_path" | "application_root",
|
||||
selected: string,
|
||||
): Promise<
|
||||
Result<
|
||||
string,
|
||||
{ readonly issue: AnalysisInputIssue; readonly cause: unknown }
|
||||
>
|
||||
> => {
|
||||
try {
|
||||
return ok(await realpath(selected));
|
||||
} catch (cause: unknown) {
|
||||
return err({
|
||||
issue: {
|
||||
path: [field],
|
||||
reason: "invalid_value",
|
||||
message: `Cannot resolve ${field} ${selected}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
},
|
||||
cause,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const canonicalizeInput = async (
|
||||
input: ElectronActiveObservationInput,
|
||||
): Promise<Result<CanonicalElectronActiveObservationInput, AnalysisError>> => {
|
||||
try {
|
||||
const [executablePath, applicationPath] = await Promise.all([
|
||||
realpath(input.executable_path),
|
||||
realpath(input.application_path),
|
||||
]);
|
||||
const applicationRoot =
|
||||
input.application_root === undefined
|
||||
? dirname(applicationPath)
|
||||
: await realpath(input.application_root);
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...input,
|
||||
executable_path: executablePath,
|
||||
application_path: applicationPath,
|
||||
application_root: applicationRoot,
|
||||
},
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
const [executable, application, root] = await Promise.all([
|
||||
resolveInputPath("executable_path", input.executable_path),
|
||||
resolveInputPath("application_path", input.application_path),
|
||||
input.application_root === undefined
|
||||
? Promise.resolve(ok(undefined))
|
||||
: resolveInputPath("application_root", input.application_root),
|
||||
]);
|
||||
if (!executable.ok || !application.ok || !root.ok) {
|
||||
const failures = [executable, application, root].flatMap((result) =>
|
||||
result.ok ? [] : [result.error],
|
||||
);
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, { cause }, [
|
||||
new AnalysisInputError(
|
||||
OPERATION,
|
||||
{
|
||||
path: ["application_path"],
|
||||
reason: "invalid_format",
|
||||
message:
|
||||
"Electron executable, application, and root must resolve to existing paths",
|
||||
cause: new AggregateError(
|
||||
failures.map(({ cause }) => cause),
|
||||
"Electron paths could not be resolved",
|
||||
),
|
||||
},
|
||||
]),
|
||||
failures.map(({ issue }) => issue),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ok({
|
||||
...input,
|
||||
executable_path: executable.value,
|
||||
application_path: application.value,
|
||||
application_root: root.value ?? dirname(application.value),
|
||||
});
|
||||
};
|
||||
|
||||
/** Canonicalize request paths and execute one provider-owned Electron run. */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import * as processOwnership from "../process/ProcessOwnership.js";
|
||||
import * as electronActions from "./PlaywrightElectronActiveActions.js";
|
||||
|
|
@ -28,12 +29,13 @@ it("passes the selected environment and ownership token to Electron launch", asy
|
|||
throw new Error("launch boundary reached");
|
||||
},
|
||||
);
|
||||
const result = await provider.capture(
|
||||
electronActiveObservationInputSchema.parse({
|
||||
const result = await provider.capture({
|
||||
...electronActiveObservationInputSchema.parse({
|
||||
executable_path: process.execPath,
|
||||
application_path: process.execPath,
|
||||
}),
|
||||
);
|
||||
application_root: dirname(process.execPath),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(launchedEnvironment).toEqual({
|
||||
|
|
@ -151,12 +153,13 @@ it("retains the partial capture without taskkill when Windows lineage is unavail
|
|||
);
|
||||
|
||||
try {
|
||||
const result = await provider.capture(
|
||||
electronActiveObservationInputSchema.parse({
|
||||
const result = await provider.capture({
|
||||
...electronActiveObservationInputSchema.parse({
|
||||
executable_path: process.execPath,
|
||||
application_path: process.execPath,
|
||||
}),
|
||||
);
|
||||
application_root: dirname(process.execPath),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected unverified cleanup to fail");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { realpath } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
|
|
@ -9,11 +7,11 @@ import type {
|
|||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { ElectronActiveObservationPort } from "../application/javascript/ElectronActiveObservationPort.js";
|
||||
import type {
|
||||
ElectronActiveObservationInput,
|
||||
ElectronActiveObservationResult,
|
||||
} from "../domain/javascript/electronActiveObservation.js";
|
||||
CanonicalElectronActiveObservationInput,
|
||||
ElectronActiveObservationPort,
|
||||
} from "../application/javascript/ElectronActiveObservationPort.js";
|
||||
import type { ElectronActiveObservationResult } from "../domain/javascript/electronActiveObservation.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
|
|
@ -70,7 +68,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
|
|||
}
|
||||
|
||||
async capture(
|
||||
input: ElectronActiveObservationInput,
|
||||
input: CanonicalElectronActiveObservationInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<ElectronActiveObservationResult, AnalysisError>> {
|
||||
let application: ElectronApplication | undefined;
|
||||
|
|
@ -89,7 +87,11 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
|
|||
throw new BrowserObservationError(OPERATION, "cancelled");
|
||||
await prepareProcessOwnershipInspection(options.signal);
|
||||
const startupDeadline = Date.now() + STARTUP_TIMEOUT_MS;
|
||||
paths = await canonicalPaths(input);
|
||||
paths = {
|
||||
executable: input.executable_path,
|
||||
application: input.application_path,
|
||||
root: input.application_root,
|
||||
};
|
||||
const captureBaseline =
|
||||
await systemProcessOwnershipHost.captureBaseline?.(options.signal);
|
||||
if (options.signal?.aborted)
|
||||
|
|
@ -353,20 +355,6 @@ export const cleanupElectronProcesses = async (
|
|||
};
|
||||
};
|
||||
|
||||
const canonicalPaths = async (
|
||||
input: ElectronActiveObservationInput,
|
||||
): Promise<ElectronPaths> => {
|
||||
const [executable, application] = await Promise.all([
|
||||
realpath(input.executable_path),
|
||||
realpath(input.application_path),
|
||||
]);
|
||||
const root =
|
||||
input.application_root === undefined
|
||||
? dirname(application)
|
||||
: await realpath(input.application_root);
|
||||
return { executable, application, root };
|
||||
};
|
||||
|
||||
const safeElectronEnvironment = (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
runId: string,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest(
|
||||
"reports every unresolved Electron path through CLI and MCP without launching",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-electron-invalid-paths-");
|
||||
const application = join(root, "main.js");
|
||||
await writeFile(application, "module.exports = {};\n");
|
||||
const missing = {
|
||||
executable_path: join(root, "missing-electron"),
|
||||
application_path: join(root, "missing-application"),
|
||||
application_root: join(root, "missing-root"),
|
||||
};
|
||||
const requestPath = join(root, "input.json");
|
||||
await writeFile(requestPath, JSON.stringify(missing));
|
||||
const response = await cli.run({
|
||||
arguments: ["capture-electron-scenario", requestPath, "--format", "json"],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(response.exitCode).toBe(1);
|
||||
const expectedIssues = Object.entries(missing).map(([field, path]) => ({
|
||||
path: [field],
|
||||
reason: "invalid_value",
|
||||
message: expect.stringContaining(path),
|
||||
}));
|
||||
expect(response.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: { issues: expectedIssues },
|
||||
});
|
||||
expect(response.stdout).toContain("ENOENT");
|
||||
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
for (const [field, path] of Object.entries(missing)) {
|
||||
const result = await call("capture_electron_scenario", {
|
||||
executable_path: process.execPath,
|
||||
application_path: application,
|
||||
application_root: root,
|
||||
[field]: path,
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const content = result.content.find((item) => item.type === "text");
|
||||
if (content?.type !== "text") throw new Error("Missing error response");
|
||||
expect(JSON.parse(content.text)).toMatchObject({
|
||||
error: {
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
path: [field],
|
||||
reason: "invalid_value",
|
||||
message: expect.stringContaining(path),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(content.text).toContain("ENOENT");
|
||||
}
|
||||
const session = await call("binary_session", {});
|
||||
expect(session.isError, JSON.stringify(session)).not.toBe(true);
|
||||
},
|
||||
);
|
||||
|
|
@ -903,7 +903,7 @@ it.skipIf(process.platform === "win32")(
|
|||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"maps cancellation during native preparation before Electron path resolution",
|
||||
"maps cancellation during native preparation before Electron launch",
|
||||
async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), "rea-electron-token-abort-test-"),
|
||||
|
|
@ -926,7 +926,10 @@ it.skipIf(process.platform === "win32")(
|
|||
application_path: "/missing/application.js",
|
||||
});
|
||||
captureOutcome = new PlaywrightElectronActiveProvider({})
|
||||
.capture(input, { signal: controller.signal })
|
||||
.capture(
|
||||
{ ...input, application_root: process.cwd() },
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then(
|
||||
(value) => ({ state: "result" as const, value }),
|
||||
(cause: unknown) => ({ state: "rejected" as const, cause }),
|
||||
|
|
@ -1053,7 +1056,10 @@ it.skipIf(process.platform === "win32")(
|
|||
application_root: process.cwd(),
|
||||
});
|
||||
captureOutcome = new PlaywrightElectronActiveProvider({})
|
||||
.capture(input, { signal: controller.signal })
|
||||
.capture(
|
||||
{ ...input, application_root: process.cwd() },
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then(
|
||||
(value) => ({ state: "result" as const, value }),
|
||||
(cause: unknown) => ({ state: "rejected" as const, cause }),
|
||||
|
|
|
|||
Loading…
Reference in New Issue