diff --git a/scripts/verify-real-electron.mjs b/scripts/verify-real-electron.mjs index c60fd92a1..8f647e649 100644 --- a/scripts/verify-real-electron.mjs +++ b/scripts/verify-real-electron.mjs @@ -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", ), diff --git a/src/application/javascript/ElectronActiveEvidence.ts b/src/application/javascript/ElectronActiveEvidence.ts index 489cd7caa..cbd844e26 100644 --- a/src/application/javascript/ElectronActiveEvidence.ts +++ b/src/application/javascript/ElectronActiveEvidence.ts @@ -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, diff --git a/src/application/javascript/ElectronActiveObservationPort.ts b/src/application/javascript/ElectronActiveObservationPort.ts index 56083c25e..c323842f3 100644 --- a/src/application/javascript/ElectronActiveObservationPort.ts +++ b/src/application/javascript/ElectronActiveObservationPort.ts @@ -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>; } diff --git a/src/application/javascript/ElectronActiveObservationService.ts b/src/application/javascript/ElectronActiveObservationService.ts index 5a4a84e1d..2d3036228 100644 --- a/src/application/javascript/ElectronActiveObservationService.ts +++ b/src/application/javascript/ElectronActiveObservationService.ts @@ -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> => { - 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. */ diff --git a/src/browser/PlaywrightElectronActiveProvider.test.ts b/src/browser/PlaywrightElectronActiveProvider.test.ts index 4c7b738b6..cdb2698ee 100644 --- a/src/browser/PlaywrightElectronActiveProvider.test.ts +++ b/src/browser/PlaywrightElectronActiveProvider.test.ts @@ -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"); diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index 9c05465c8..4b6e6bb14 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -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> { 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 => { - 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>, runId: string, diff --git a/tests/acceptance/applications/electronInvalidPaths.test.ts b/tests/acceptance/applications/electronInvalidPaths.test.ts new file mode 100644 index 000000000..d74c04b2b --- /dev/null +++ b/tests/acceptance/applications/electronInvalidPaths.test.ts @@ -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); + }, +); diff --git a/tests/boundary/process/darwinProcessRunTokenReader.test.ts b/tests/boundary/process/darwinProcessRunTokenReader.test.ts index 8acafbd44..bfdb047f2 100644 --- a/tests/boundary/process/darwinProcessRunTokenReader.test.ts +++ b/tests/boundary/process/darwinProcessRunTokenReader.test.ts @@ -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 }),