diff --git a/.oxlintrc.json b/.oxlintrc.json index 557dfd7c5..d9dadc6e6 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -51,7 +51,8 @@ "**/hopper/**", "**/ida/**", "**/android/**", - "**/firmware/**" + "**/firmware/**", + "**/browser/*Provider.*" ], "message": "Provider imports belong behind typed ports or in production composition." } @@ -111,6 +112,7 @@ "**/ida/**", "**/android/**", "**/firmware/**", + "**/browser/*Provider.*", "**/composition/**" ], "message": "Application workflows use typed ports; production construction belongs in composition." diff --git a/docs/capability-migration.md b/docs/capability-migration.md index 57ce4449a..97ed800e9 100644 --- a/docs/capability-migration.md +++ b/docs/capability-migration.md @@ -93,3 +93,27 @@ providers. Composition may import their implementations. Binary composition is still at its existing entrypoint while its direct-analysis callers are investigated. This concrete pilot does not introduce a shared provider interface or module manifest. + +## Independent optional observation adapters + +Browser observation, browser scenarios, Electron observation, Electron scenarios +and V8 Inspector observation now have separate factories in `src/composition/`. +CLI commands reuse them; MCP startup dynamically imports each factory independently. +`OptionalObservationProviders` describes these five existing typed ports only. It +is provisional startup wiring, not an extensible plugin manifest or a universal +provider interface. + +A failed import or constructor retains the successful peers. The complete tool +catalog remains advertised. Session availability reports the failed adapter and +its actual reason, and affected handlers return the existing capability-unavailable +error. Explicit loading failures take precedence over configured availability. +Passive Electron and active Electron failures are independent; static JavaScript +analysis and runtime reconciliation remain usable without either adapter. CLI +Electron commands also load only their selected runtime factory at execution. + +Source identities are available without importing the optional implementations. +Registration acquires no engines, probes no endpoints, changes no permissions and +installs nothing. Core transport failures and shutdown still use their existing +lifecycle. Fault-injection coverage uses the production loading/startup path and +actual MCP SDK calls; recording ports establish composition behavior, while the +real Inspector lane separately establishes runtime behavior. diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index a7b277970..441d5e642 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -1,4 +1,8 @@ import { z } from "zod"; +import type { + OptionalProviderLoadFailures, + OptionalProviderLoadFailure, +} from "./OptionalObservationProviders.js"; import { providerCapability, @@ -28,6 +32,7 @@ type ToolAvailabilityReason = "available" | ToolUnavailabilityReason; type ProviderDescriptor = ProviderCapability; export type AvailabilityPolicy = { readonly processCaptureEnabled: boolean; + readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures; readonly firmwareInspectionEnabled?: boolean; readonly firmwareExtractionEnabled?: boolean; readonly androidAnalysisEnabled?: boolean; @@ -186,6 +191,12 @@ const availabilityFor = (context: AvailabilityContext): Availability => { return navigationContextAvailability(context.descriptors); const javascriptApplication = javascriptApplicationAvailability(context); if (javascriptApplication !== null) return javascriptApplication; + const failure = optionalProviderFailureFor(context); + if (failure !== undefined) + return { + reason: "provider_unavailable", + remediation: `Adapter ${failure.providerId} could not load: ${failure.reason}. Repair the adapter installation and restart REA; other capabilities remain available.`, + }; const workflowAvailability = workflowAvailabilityFor(context); if (workflowAvailability !== null) return workflowAvailability; const targetDecision = targetAvailability(context); @@ -193,6 +204,23 @@ const availabilityFor = (context: AvailabilityContext): Availability => { return providerAvailability(context); }; +const optionalProviderFailureFor = ({ + name, + kind, + policy, +}: AvailabilityContext): OptionalProviderLoadFailure | undefined => { + const failures = policy.optionalProviderLoadFailures; + if (name === "capture_browser_scenario") + return failures?.browserScenarioCapture; + if (kind === "browser-provider") return failures?.browserObservation; + if (name === "capture_electron_scenario") + return failures?.electronActiveObservation; + if (kind === "electron-provider") return failures?.electronObservation; + if (kind === "runtime-provider") + return failures?.javascriptRuntimeObservation; + return undefined; +}; + const javascriptApplicationAvailability = ({ name, }: AvailabilityContext): Availability | null => { diff --git a/src/application/OptionalObservationProviders.ts b/src/application/OptionalObservationProviders.ts new file mode 100644 index 000000000..80ad5e9ef --- /dev/null +++ b/src/application/OptionalObservationProviders.ts @@ -0,0 +1,43 @@ +import type { BrowserObservationPort } from "./BrowserObservationPort.js"; +import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js"; +import type { ElectronObservationPort } from "./ElectronObservationPort.js"; +import type { ElectronActiveObservationPort } from "./ElectronActiveObservationPort.js"; +import type { JavaScriptRuntimeObservationPort } from "./JavaScriptRuntimeObservationPort.js"; +import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; + +/** Existing observation ports loaded independently at MCP startup. */ +export interface OptionalObservationProviders { + readonly browserObservation?: BrowserObservationPort; + readonly browserScenarioCapture?: BrowserScenarioCapturePort; + readonly electronObservation?: ElectronObservationPort; + readonly electronActiveObservation?: ElectronActiveObservationPort; + readonly javascriptRuntimeObservation?: JavaScriptRuntimeObservationPort; +} + +/** A module failed before its port could be constructed; no engine was opened. */ +export interface OptionalProviderLoadFailure { + readonly providerId: string; + readonly reason: string; +} + +export type OptionalProviderLoadFailures = Readonly< + Partial< + Record + > +>; + +/** Successful ports and failed peers from one independent loading attempt. */ +export interface OptionalProviderLoadResult extends OptionalObservationProviders { + readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures; +} + +/** Preserve the module's failure reason through the existing tool error algebra. */ +export const optionalProviderUnavailable = ( + failure: OptionalProviderLoadFailure, + operation: string, +): AnalysisCapabilityUnavailableError => + new AnalysisCapabilityUnavailableError( + failure.providerId, + operation, + `Adapter could not load: ${failure.reason}`, + ); diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts index de37f524e..6648beaba 100644 --- a/src/browser/CdpBrowserProvider.ts +++ b/src/browser/CdpBrowserProvider.ts @@ -64,11 +64,8 @@ import { captureCdpScreenshot } from "./CdpScreenshot.js"; import { comparePngScreenshots } from "./PngVisualDiff.js"; /** Public identity committed by passive browser observations. */ -export const CDP_BROWSER_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({ - id: "rea-cdp-browser", - name: "REA Chrome DevTools Protocol observation provider", - version: "2", -}); +export { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js"; +import { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js"; const IDENTITY = CDP_BROWSER_PROVIDER_IDENTITY; const CLEANUP_DOMAINS = ["Network", "Debugger", "Runtime", "Page"] as const; diff --git a/src/browser/CdpElectronProvider.ts b/src/browser/CdpElectronProvider.ts index 088bf29a3..7c72ec469 100644 --- a/src/browser/CdpElectronProvider.ts +++ b/src/browser/CdpElectronProvider.ts @@ -30,11 +30,8 @@ import { inspectCdpElectronPage } from "./CdpElectronInspection.js"; import { authorizedElectronFile } from "./ElectronFileScope.js"; /** Public identity committed by passive Electron observations. */ -export const CDP_ELECTRON_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({ - id: "rea-cdp-electron", - name: "REA Electron file-page CDP observation provider", - version: "1", -}); +export { CDP_ELECTRON_PROVIDER_IDENTITY } from "./providerIdentities.js"; +import { CDP_ELECTRON_PROVIDER_IDENTITY } from "./providerIdentities.js"; const IDENTITY = CDP_ELECTRON_PROVIDER_IDENTITY; /** Passive Electron provider for local file pages exposed by loopback CDP. */ diff --git a/src/browser/PlaywrightBrowserScenarioProvider.ts b/src/browser/PlaywrightBrowserScenarioProvider.ts index 3b3634126..ac8ed2ed2 100644 --- a/src/browser/PlaywrightBrowserScenarioProvider.ts +++ b/src/browser/PlaywrightBrowserScenarioProvider.ts @@ -41,12 +41,8 @@ const lazyPlaywrightFactory: BrowserScenarioSessionFactory = { }, }; -export const PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY: ProviderIdentity = - Object.freeze({ - id: "rea-playwright-browser-scenario", - name: "REA Playwright browser scenario capture provider", - version: "1", - }); +export { PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY } from "./providerIdentities.js"; +import { PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY } from "./providerIdentities.js"; type SnapshotKind = BrowserScenario["capture"]["after_each_step"][number]; const SNAPSHOT_KINDS = [ diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index 88850ee23..8075ff082 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -42,12 +42,8 @@ const OPERATION = "capture_electron_scenario" as const; const STARTUP_TIMEOUT_MS = 60_000; /** Public identity for provider-owned Electron runtime experiments. */ -export const PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY: ProviderIdentity = - Object.freeze({ - id: "rea-playwright-electron-active", - name: "REA Playwright active Electron observation provider", - version: "1", - }); +export { PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY } from "./providerIdentities.js"; +import { PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY } from "./providerIdentities.js"; const hookPath = fileURLToPath( new URL("../../scripts/electron-active-hook.cjs", import.meta.url), diff --git a/src/browser/V8InspectorProvider.ts b/src/browser/V8InspectorProvider.ts index 6297aca11..ef0a3fb56 100644 --- a/src/browser/V8InspectorProvider.ts +++ b/src/browser/V8InspectorProvider.ts @@ -39,11 +39,8 @@ import { } from "./V8InspectorEndpoint.js"; /** Public identity committed by passive V8 Inspector observations. */ -export const V8_INSPECTOR_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({ - id: "rea-v8-inspector", - name: "REA passive Node/Electron V8 Inspector provider", - version: "1", -}); +export { V8_INSPECTOR_PROVIDER_IDENTITY } from "./providerIdentities.js"; +import { V8_INSPECTOR_PROVIDER_IDENTITY } from "./providerIdentities.js"; export interface ScriptDraft { readonly rawUrl: string; diff --git a/src/browser/providerIdentities.ts b/src/browser/providerIdentities.ts new file mode 100644 index 000000000..7ae4a8b53 --- /dev/null +++ b/src/browser/providerIdentities.ts @@ -0,0 +1,38 @@ +import type { ProviderIdentity } from "../application/AnalysisProvider.js"; + +/** Identity available without loading the CdpBrowserProvider implementation. */ +export const CDP_BROWSER_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({ + id: "rea-cdp-browser", + name: "REA Chrome DevTools Protocol observation provider", + version: "2", +}); + +/** Identity available without loading the PlaywrightBrowserScenarioProvider implementation. */ +export const PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY: ProviderIdentity = + Object.freeze({ + id: "rea-playwright-browser-scenario", + name: "REA Playwright browser scenario capture provider", + version: "1", + }); + +/** Identity available without loading the CdpElectronProvider implementation. */ +export const CDP_ELECTRON_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({ + id: "rea-cdp-electron", + name: "REA Electron file-page CDP observation provider", + version: "1", +}); + +/** Identity available without loading the PlaywrightElectronActiveProvider implementation. */ +export const PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY: ProviderIdentity = + Object.freeze({ + id: "rea-playwright-electron-active", + name: "REA Playwright active Electron observation provider", + version: "1", + }); + +/** Identity available without loading the V8InspectorProvider implementation. */ +export const V8_INSPECTOR_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({ + id: "rea-v8-inspector", + name: "REA passive Node/Electron V8 Inspector provider", + version: "1", +}); diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index 899cd0b9a..1f17647e3 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -6,8 +6,6 @@ import { } from "../application/ElectronObservationService.js"; import { captureElectronScenario } from "../application/ElectronActiveObservationService.js"; import { reconcileJavaScriptRuntimeEvidence } from "../application/JavaScriptRuntimeReconciliationService.js"; -import { CdpElectronProvider } from "../browser/CdpElectronProvider.js"; -import { PlaywrightElectronActiveProvider } from "../browser/PlaywrightElectronActiveProvider.js"; import { logCliCommand } from "../cliLogging.js"; import { inspectElectronPageInputSchema, @@ -61,9 +59,10 @@ const registerElectronActiveCommand = ( input.value, ); if (!parsed.success) return inputError("capture_electron_scenario"); - const context = await electronContext(); + const { createElectronScenarioProvider } = + await import("../composition/electronScenario.js"); const result = await captureElectronScenario( - context.activeProvider, + createElectronScenarioProvider(), parsed.data, ); return result.ok ? result.value : cliError(result.error); @@ -181,16 +180,12 @@ const registerJavaScriptApplicationCommand = ( }); }; -const electronContext = async () => { - return { - ok: true as const, - provider: new CdpElectronProvider(), - activeProvider: new PlaywrightElectronActiveProvider(), - }; +const electronObservationContext = async () => { + const { createElectronObservationProvider } = + await import("../composition/electronObservation.js"); + return { provider: createElectronObservationProvider() }; }; -const electronObservationContext = electronContext; - const inputError = (operation: string): JsonValue => cliError(new AnalysisInputError(operation)); diff --git a/src/cliBrowserContext.ts b/src/cliBrowserContext.ts index 85bbe155d..bd5169e6b 100644 --- a/src/cliBrowserContext.ts +++ b/src/cliBrowserContext.ts @@ -1,9 +1,11 @@ -import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js"; +import { createBrowserObservationProvider } from "./composition/browserObservation.js"; import { projectAnalysisError } from "./domain/analysisErrorProjection.js"; import type { JsonValue } from "./domain/jsonValue.js"; /** Build the passive browser provider; scope is supplied on each request. */ -export const browserContext = () => ({ provider: new CdpBrowserProvider() }); +export const browserContext = () => ({ + provider: createBrowserObservationProvider(), +}); /** Project a browser operation failure consistently across CLI command groups. */ export const browserCliError = ( diff --git a/src/cliBrowserScenarioCommands.ts b/src/cliBrowserScenarioCommands.ts index fbf11ae5c..877eae813 100644 --- a/src/cliBrowserScenarioCommands.ts +++ b/src/cliBrowserScenarioCommands.ts @@ -2,7 +2,7 @@ import { Cli } from "incur"; import { z } from "zod"; import { captureBrowserScenario } from "./application/BrowserScenarioCaptureService.js"; -import { PlaywrightBrowserScenarioProvider } from "./browser/PlaywrightBrowserScenarioProvider.js"; +import { createBrowserScenarioProvider } from "./composition/browserScenario.js"; import { CLI_COMMANDS } from "./cliCommandNames.js"; import { parseCliJsonInput } from "./cliJsonInput.js"; import { logCliCommand } from "./cliLogging.js"; @@ -35,7 +35,7 @@ export const registerBrowserScenarioCommands = ( if (!scenario.success) return cliError(new AnalysisInputError(OPERATION)); const result = await captureBrowserScenario( - new PlaywrightBrowserScenarioProvider(), + createBrowserScenarioProvider(), scenario.data, ); return result.ok ? result.value : cliError(result.error); diff --git a/src/cliJavaScriptRuntimeCommands.ts b/src/cliJavaScriptRuntimeCommands.ts index 342fc751b..882a9912c 100644 --- a/src/cliJavaScriptRuntimeCommands.ts +++ b/src/cliJavaScriptRuntimeCommands.ts @@ -4,7 +4,7 @@ import { listJavaScriptRuntimeTargets, observeJavaScriptRuntime, } from "./application/JavaScriptRuntimeObservationService.js"; -import { V8InspectorProvider } from "./browser/V8InspectorProvider.js"; +import { createJavaScriptRuntimeObservationProvider } from "./composition/javascriptRuntimeObservation.js"; import { CLI_COMMANDS } from "./cliCommandNames.js"; import { logCliCommand } from "./cliLogging.js"; import { @@ -89,7 +89,9 @@ export const registerJavaScriptRuntimeObservationCommands = ( }); }; -const runtimeContext = () => ({ provider: new V8InspectorProvider() }); +const runtimeContext = () => ({ + provider: createJavaScriptRuntimeObservationProvider(), +}); const inputError = (operation: string): JsonValue => cliError(new AnalysisInputError(operation)); diff --git a/src/composition/browserObservation.ts b/src/composition/browserObservation.ts new file mode 100644 index 000000000..5d4e2e934 --- /dev/null +++ b/src/composition/browserObservation.ts @@ -0,0 +1,6 @@ +import type { BrowserObservationPort } from "../application/BrowserObservationPort.js"; +import { CdpBrowserProvider } from "../browser/CdpBrowserProvider.js"; + +/** Construct a fresh provider without opening a target or acquiring an engine. */ +export const createBrowserObservationProvider = (): BrowserObservationPort => + new CdpBrowserProvider(); diff --git a/src/composition/browserScenario.ts b/src/composition/browserScenario.ts new file mode 100644 index 000000000..4c59c62a9 --- /dev/null +++ b/src/composition/browserScenario.ts @@ -0,0 +1,6 @@ +import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js"; +import { PlaywrightBrowserScenarioProvider } from "../browser/PlaywrightBrowserScenarioProvider.js"; + +/** Construct a fresh provider without opening a target or acquiring an engine. */ +export const createBrowserScenarioProvider = (): BrowserScenarioCapturePort => + new PlaywrightBrowserScenarioProvider(); diff --git a/src/composition/electronObservation.ts b/src/composition/electronObservation.ts new file mode 100644 index 000000000..de51b372e --- /dev/null +++ b/src/composition/electronObservation.ts @@ -0,0 +1,6 @@ +import type { ElectronObservationPort } from "../application/ElectronObservationPort.js"; +import { CdpElectronProvider } from "../browser/CdpElectronProvider.js"; + +/** Construct a fresh provider without opening a target or acquiring an engine. */ +export const createElectronObservationProvider = (): ElectronObservationPort => + new CdpElectronProvider(); diff --git a/src/composition/electronScenario.ts b/src/composition/electronScenario.ts new file mode 100644 index 000000000..348f3c726 --- /dev/null +++ b/src/composition/electronScenario.ts @@ -0,0 +1,6 @@ +import type { ElectronActiveObservationPort } from "../application/ElectronActiveObservationPort.js"; +import { PlaywrightElectronActiveProvider } from "../browser/PlaywrightElectronActiveProvider.js"; + +/** Construct a fresh provider without opening a target or acquiring an engine. */ +export const createElectronScenarioProvider = + (): ElectronActiveObservationPort => new PlaywrightElectronActiveProvider(); diff --git a/src/composition/javascriptRuntimeObservation.ts b/src/composition/javascriptRuntimeObservation.ts new file mode 100644 index 000000000..80579b703 --- /dev/null +++ b/src/composition/javascriptRuntimeObservation.ts @@ -0,0 +1,6 @@ +import type { JavaScriptRuntimeObservationPort } from "../application/JavaScriptRuntimeObservationPort.js"; +import { V8InspectorProvider } from "../browser/V8InspectorProvider.js"; + +/** Construct a fresh provider without opening a target or acquiring an engine. */ +export const createJavaScriptRuntimeObservationProvider = + (): JavaScriptRuntimeObservationPort => new V8InspectorProvider(); diff --git a/src/composition/optionalObservationProviders.ts b/src/composition/optionalObservationProviders.ts new file mode 100644 index 000000000..5b48ce8f5 --- /dev/null +++ b/src/composition/optionalObservationProviders.ts @@ -0,0 +1,97 @@ +import type { + OptionalObservationProviders, + OptionalProviderLoadFailure, + OptionalProviderLoadResult, +} from "../application/OptionalObservationProviders.js"; +import { + CDP_BROWSER_PROVIDER_IDENTITY, + PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY, + CDP_ELECTRON_PROVIDER_IDENTITY, + PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY, + V8_INSPECTOR_PROVIDER_IDENTITY, +} from "../browser/providerIdentities.js"; +import { err, ok, type Result } from "../domain/result.js"; + +/** Precisely typed startup factories for the five existing observation ports. */ +export type OptionalObservationFactories = { + readonly [Port in keyof OptionalObservationProviders]-?: () => Promise< + NonNullable + >; +}; + +const productionFactories: OptionalObservationFactories = { + browserObservation: async () => + ( + await import("./browserObservation.js") + ).createBrowserObservationProvider(), + browserScenarioCapture: async () => + (await import("./browserScenario.js")).createBrowserScenarioProvider(), + electronObservation: async () => + ( + await import("./electronObservation.js") + ).createElectronObservationProvider(), + electronActiveObservation: async () => + (await import("./electronScenario.js")).createElectronScenarioProvider(), + javascriptRuntimeObservation: async () => + ( + await import("./javascriptRuntimeObservation.js") + ).createJavaScriptRuntimeObservationProvider(), +}; + +const loadPort = async ( + factory: () => Promise, + providerId: string, +): Promise> => { + try { + return ok(await factory()); + } catch (cause: unknown) { + return err({ + providerId, + reason: cause instanceof Error ? cause.message : String(cause), + }); + } +}; + +/** Load each adapter independently, retaining healthy peers and exact failures. */ +export const loadOptionalObservationProviders = async ( + factories: OptionalObservationFactories = productionFactories, +): Promise => { + const [browser, scenario, electron, electronActive, runtime] = + await Promise.all([ + loadPort(factories.browserObservation, CDP_BROWSER_PROVIDER_IDENTITY.id), + loadPort( + factories.browserScenarioCapture, + PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY.id, + ), + loadPort( + factories.electronObservation, + CDP_ELECTRON_PROVIDER_IDENTITY.id, + ), + loadPort( + factories.electronActiveObservation, + PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY.id, + ), + loadPort( + factories.javascriptRuntimeObservation, + V8_INSPECTOR_PROVIDER_IDENTITY.id, + ), + ]); + return { + ...(browser.ok ? { browserObservation: browser.value } : {}), + ...(scenario.ok ? { browserScenarioCapture: scenario.value } : {}), + ...(electron.ok ? { electronObservation: electron.value } : {}), + ...(electronActive.ok + ? { electronActiveObservation: electronActive.value } + : {}), + ...(runtime.ok ? { javascriptRuntimeObservation: runtime.value } : {}), + optionalProviderLoadFailures: { + ...(browser.ok ? {} : { browserObservation: browser.error }), + ...(scenario.ok ? {} : { browserScenarioCapture: scenario.error }), + ...(electron.ok ? {} : { electronObservation: electron.error }), + ...(electronActive.ok + ? {} + : { electronActiveObservation: electronActive.error }), + ...(runtime.ok ? {} : { javascriptRuntimeObservation: runtime.error }), + }, + }; +}; diff --git a/src/main.ts b/src/main.ts index 920b0a694..5a2009738 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,7 @@ import { SERVER_START_FAILED } from "./main/messages.js"; import { createRuntimeState } from "./main/state.js"; import { openInitialTarget } from "./main/startup.js"; import { startMcpTransport } from "./main/transport.js"; +import { loadOptionalObservationProviders } from "./composition/optionalObservationProviders.js"; import { registerConfigReload } from "./main/reload.js"; import { createShutdown } from "./main/shutdown.js"; @@ -70,29 +71,7 @@ export const run = async ( logger, serverLogger, loadOptionalProviders: - dependencies.loadOptionalProviders ?? - (async () => { - const [ - { CdpBrowserProvider }, - { PlaywrightBrowserScenarioProvider }, - { CdpElectronProvider }, - { PlaywrightElectronActiveProvider }, - { V8InspectorProvider }, - ] = await Promise.all([ - import("./browser/CdpBrowserProvider.js"), - import("./browser/PlaywrightBrowserScenarioProvider.js"), - import("./browser/CdpElectronProvider.js"), - import("./browser/PlaywrightElectronActiveProvider.js"), - import("./browser/V8InspectorProvider.js"), - ]); - return { - browserObservation: new CdpBrowserProvider(), - browserScenarioCapture: new PlaywrightBrowserScenarioProvider(), - electronObservation: new CdpElectronProvider(), - electronActiveObservation: new PlaywrightElectronActiveProvider(), - javascriptRuntimeObservation: new V8InspectorProvider(), - }; - }), + dependencies.loadOptionalProviders ?? loadOptionalObservationProviders, }); if (!transport.ok) return 1; const unregisterReload = registerConfigReload({ diff --git a/src/main/transport.ts b/src/main/transport.ts index 5235419d1..dcaa683e1 100644 --- a/src/main/transport.ts +++ b/src/main/transport.ts @@ -2,25 +2,16 @@ import type { StdioServerHandle } from "@modelcontextprotocol/server/stdio"; import type { BinarySession } from "../application/BinarySession.js"; import type { Logger } from "../logger.js"; -import { - createServer, - type CreateServerOptions, -} from "../server/createServer.js"; +import { createServer } from "../server/createServer.js"; import type { RuntimeDependencies } from "./types.js"; +import type { OptionalProviderLoadResult } from "../application/OptionalObservationProviders.js"; import { MCP_CONNECTION_LOST, MCP_CONNECTION_START_FAILED, } from "./messages.js"; /** Optional adapters whose absence must not prevent the core MCP server. */ -export type OptionalProviders = Pick< - CreateServerOptions, - | "browserObservation" - | "browserScenarioCapture" - | "electronObservation" - | "electronActiveObservation" - | "javascriptRuntimeObservation" ->; +export type OptionalProviders = OptionalProviderLoadResult; interface ServerContext { readonly logger: Logger; @@ -51,6 +42,14 @@ export const startMcpTransport = async ( "Optional MCP providers could not load; affected tools remain unavailable", ); } + for (const failure of Object.values( + optionalProviders.optionalProviderLoadFailures ?? {}, + )) { + serverLogger.warn( + { providerId: failure.providerId, error: failure.reason }, + "Optional MCP adapter could not load; its peers remain available", + ); + } let handle: StdioServerHandle; try { handle = dependencies.serve( diff --git a/src/server/createServer.ts b/src/server/createServer.ts index f75187629..5b6478a29 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -8,6 +8,7 @@ import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioC import type { ElectronActiveObservationPort } from "../application/ElectronActiveObservationPort.js"; import type { ElectronObservationPort } from "../application/ElectronObservationPort.js"; import type { JavaScriptRuntimeObservationPort } from "../application/JavaScriptRuntimeObservationPort.js"; +import type { OptionalProviderLoadFailures } from "../application/OptionalObservationProviders.js"; import { PRODUCT_IDENTITY } from "../identity.js"; import { silentLogger, type Logger } from "../logger.js"; import { registerApplicationTools } from "./registerApplicationTools.js"; @@ -51,6 +52,7 @@ export interface CreateServerOptions { readonly electronActiveObservation?: ElectronActiveObservationPort; readonly javascriptRuntimeObservation?: JavaScriptRuntimeObservationPort; readonly availabilityPolicy?: () => SessionAvailability; + readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures; } const installSessionToolAvailability = ( @@ -60,6 +62,7 @@ const installSessionToolAvailability = ( ) => { if (session === undefined) return undefined; const policy = sessionAvailabilityPolicy(options.availabilityPolicy, { + optionalProviderLoadFailures: options.optionalProviderLoadFailures, optionalFeatures: { firmwareInspectionEnabled: options.firmwareAnalysis !== undefined || @@ -259,19 +262,27 @@ const registerObservationTools = ({ registerBrowserTools(server, { ...common, browser: options.browserObservation, + loadFailure: options.optionalProviderLoadFailures?.browserObservation, }); registerBrowserScenarioTool(server, { ...common, provider: options.browserScenarioCapture, + loadFailure: options.optionalProviderLoadFailures?.browserScenarioCapture, }); registerElectronTools(server, { ...common, electron: options.electronObservation, electronActive: options.electronActiveObservation, + observationLoadFailure: + options.optionalProviderLoadFailures?.electronObservation, + activeLoadFailure: + options.optionalProviderLoadFailures?.electronActiveObservation, }); registerJavaScriptRuntimeObservationTools(server, { ...common, runtime: options.javascriptRuntimeObservation, + loadFailure: + options.optionalProviderLoadFailures?.javascriptRuntimeObservation, }); registerApplicationTools(server, { evidenceById, diff --git a/src/server/registerBrowserScenarioTool.ts b/src/server/registerBrowserScenarioTool.ts index d7a390c10..875eaa586 100644 --- a/src/server/registerBrowserScenarioTool.ts +++ b/src/server/registerBrowserScenarioTool.ts @@ -1,3 +1,8 @@ +import { + optionalProviderUnavailable, + type OptionalProviderLoadFailure, +} from "../application/OptionalObservationProviders.js"; +import { err } from "../domain/result.js"; import type { McpServer } from "@modelcontextprotocol/server"; import type { BinarySessionPort } from "../application/BinarySession.js"; @@ -12,6 +17,7 @@ import { toCallToolResult } from "./toolResult.js"; interface BrowserScenarioToolRegistration { readonly logger: Logger; + readonly loadFailure?: OptionalProviderLoadFailure | undefined; readonly provider: BrowserScenarioCapturePort | undefined; readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined; } @@ -27,6 +33,11 @@ export const registerBrowserScenarioTool = ( toolRegistrationOptions(contract), async (input, context) => { const scenario = browserScenarioSchema.parse(input); + if (options.loadFailure !== undefined) + return toCallToolResult( + err(optionalProviderUnavailable(options.loadFailure, contract.name)), + contract, + ); const result = await logToolExecution(options.logger, contract.name, () => captureBrowserScenario(options.provider, scenario, { signal: context.mcpReq.signal, diff --git a/src/server/registerBrowserTools.ts b/src/server/registerBrowserTools.ts index 80f7eff24..426ce248b 100644 --- a/src/server/registerBrowserTools.ts +++ b/src/server/registerBrowserTools.ts @@ -1,3 +1,8 @@ +import { + optionalProviderUnavailable, + type OptionalProviderLoadFailure, +} from "../application/OptionalObservationProviders.js"; +import { err } from "../domain/result.js"; import type { McpServer, ServerContext } from "@modelcontextprotocol/server"; import type { BinarySessionPort } from "../application/BinarySession.js"; @@ -28,6 +33,7 @@ import { toCallToolResult } from "./toolResult.js"; interface BrowserToolRegistration { readonly logger: Logger; + readonly loadFailure?: OptionalProviderLoadFailure | undefined; readonly browser: BrowserObservationPort | undefined; readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined; } @@ -182,6 +188,11 @@ const runBrowserTool = async ( ) => Promise>, ) => { const { input, context } = request; + if (options.loadFailure !== undefined) + return toCallToolResult( + err(optionalProviderUnavailable(options.loadFailure, contract.name)), + contract, + ); const result = await logToolExecution(options.logger, contract.name, () => execute(input, { signal: context.mcpReq.signal, diff --git a/src/server/registerElectronTools.ts b/src/server/registerElectronTools.ts index e6ce905e2..ef4f7ce8f 100644 --- a/src/server/registerElectronTools.ts +++ b/src/server/registerElectronTools.ts @@ -1,3 +1,8 @@ +import { + optionalProviderUnavailable, + type OptionalProviderLoadFailure, +} from "../application/OptionalObservationProviders.js"; +import { err } from "../domain/result.js"; import type { McpServer, ServerContext } from "@modelcontextprotocol/server"; import type { BinarySessionPort } from "../application/BinarySession.js"; @@ -25,6 +30,8 @@ import { toCallToolResult } from "./toolResult.js"; interface ElectronToolRegistration { readonly logger: Logger; + readonly observationLoadFailure?: OptionalProviderLoadFailure | undefined; + readonly activeLoadFailure?: OptionalProviderLoadFailure | undefined; readonly electron: ElectronObservationPort | undefined; readonly electronActive: ElectronActiveObservationPort | undefined; readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined; @@ -134,6 +141,18 @@ const runElectronTool = async ( ) => Promise>, ) => { const { input, context } = request; + const failure = + contract.name === "capture_electron_scenario" + ? options.activeLoadFailure + : contract.name === "list_electron_targets" || + contract.name === "inspect_electron_page" + ? options.observationLoadFailure + : undefined; + if (failure !== undefined) + return toCallToolResult( + err(optionalProviderUnavailable(failure, contract.name)), + contract, + ); const result = await logToolExecution(options.logger, contract.name, () => execute(input, { signal: context.mcpReq.signal, diff --git a/src/server/registerJavaScriptRuntimeObservationTools.ts b/src/server/registerJavaScriptRuntimeObservationTools.ts index e33a50363..beb4ef62f 100644 --- a/src/server/registerJavaScriptRuntimeObservationTools.ts +++ b/src/server/registerJavaScriptRuntimeObservationTools.ts @@ -1,3 +1,8 @@ +import { + optionalProviderUnavailable, + type OptionalProviderLoadFailure, +} from "../application/OptionalObservationProviders.js"; +import { err } from "../domain/result.js"; import type { McpServer, ServerContext } from "@modelcontextprotocol/server"; import type { BinarySessionPort } from "../application/BinarySession.js"; @@ -19,6 +24,7 @@ import { toCallToolResult } from "./toolResult.js"; interface RuntimeToolRegistration { readonly logger: Logger; + readonly loadFailure?: OptionalProviderLoadFailure | undefined; readonly runtime: JavaScriptRuntimeObservationPort | undefined; readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined; } @@ -68,6 +74,11 @@ const runRuntimeTool = async ( ) => Promise>, ) => { const { input, context } = request; + if (options.loadFailure !== undefined) + return toCallToolResult( + err(optionalProviderUnavailable(options.loadFailure, contract.name)), + contract, + ); const result = await logToolExecution(options.logger, contract.name, () => execute(input, context.mcpReq.signal), ); diff --git a/src/server/sessionAvailabilityPolicy.ts b/src/server/sessionAvailabilityPolicy.ts index 21ad0f4b8..7f7523f7b 100644 --- a/src/server/sessionAvailabilityPolicy.ts +++ b/src/server/sessionAvailabilityPolicy.ts @@ -1,9 +1,13 @@ import type { AvailabilityPolicy } from "../application/CapabilityInventory.js"; +import type { OptionalProviderLoadFailures } from "../application/OptionalObservationProviders.js"; import { platform } from "node:process"; export type SessionAvailability = AvailabilityPolicy; export interface SessionAvailabilityDefaults { + readonly optionalProviderLoadFailures?: + | OptionalProviderLoadFailures + | undefined; readonly optionalFeatures?: Pick< SessionAvailability, | "browserObservationEnabled" @@ -21,24 +25,34 @@ export interface SessionAvailabilityDefaults { export const sessionAvailabilityPolicy = ( configured: (() => SessionAvailability) | undefined, defaults: SessionAvailabilityDefaults, -): (() => SessionAvailability) => - configured ?? - (() => ({ - processCaptureEnabled: platform !== "win32", - firmwareInspectionEnabled: - defaults.optionalFeatures?.firmwareInspectionEnabled ?? false, - firmwareExtractionEnabled: - defaults.optionalFeatures?.firmwareExtractionEnabled ?? false, - androidAnalysisEnabled: - defaults.optionalFeatures?.androidAnalysisEnabled ?? false, - browserObservationEnabled: - defaults.optionalFeatures?.browserObservationEnabled ?? false, - browserScenarioEnabled: - defaults.optionalFeatures?.browserScenarioEnabled ?? false, - electronObservationEnabled: - defaults.optionalFeatures?.electronObservationEnabled ?? false, - electronAutomationEnabled: - defaults.optionalFeatures?.electronAutomationEnabled ?? false, - v8InspectorObservationEnabled: - defaults.optionalFeatures?.v8InspectorObservationEnabled ?? false, - })); +): (() => SessionAvailability) => { + const policy = + configured ?? + (() => ({ + processCaptureEnabled: platform !== "win32", + firmwareInspectionEnabled: + defaults.optionalFeatures?.firmwareInspectionEnabled ?? false, + firmwareExtractionEnabled: + defaults.optionalFeatures?.firmwareExtractionEnabled ?? false, + androidAnalysisEnabled: + defaults.optionalFeatures?.androidAnalysisEnabled ?? false, + browserObservationEnabled: + defaults.optionalFeatures?.browserObservationEnabled ?? false, + browserScenarioEnabled: + defaults.optionalFeatures?.browserScenarioEnabled ?? false, + electronObservationEnabled: + defaults.optionalFeatures?.electronObservationEnabled ?? false, + electronAutomationEnabled: + defaults.optionalFeatures?.electronAutomationEnabled ?? false, + v8InspectorObservationEnabled: + defaults.optionalFeatures?.v8InspectorObservationEnabled ?? false, + })); + return () => ({ + ...policy(), + ...(defaults.optionalProviderLoadFailures === undefined + ? {} + : { + optionalProviderLoadFailures: defaults.optionalProviderLoadFailures, + }), + }); +}; diff --git a/tests/boundary/mcp/optionalProviderIsolation.test.ts b/tests/boundary/mcp/optionalProviderIsolation.test.ts new file mode 100644 index 000000000..6b93f5245 --- /dev/null +++ b/tests/boundary/mcp/optionalProviderIsolation.test.ts @@ -0,0 +1,261 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { McpServer } from "@modelcontextprotocol/server"; +import { afterEach, expect } from "vitest"; +import { z } from "zod"; + +import type { BinarySessionPort } from "../../../src/application/BinarySessionPort.js"; +import { + loadOptionalObservationProviders, + type OptionalObservationFactories, +} from "../../../src/composition/optionalObservationProviders.js"; +import { + TOOL_CONTRACTS, + toolContract, +} from "../../../src/contracts/toolContracts.js"; +import { run } from "../../../src/main.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { optionalObservationFactories } from "../../fixtures/optionalObservationProviders.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; + +const failedAdapters = [ + ["browserObservation", "list_browser_targets", "rea-cdp-browser"], + [ + "browserScenarioCapture", + "capture_browser_scenario", + "rea-playwright-browser-scenario", + ], + ["electronObservation", "list_electron_targets", "rea-cdp-electron"], + [ + "electronActiveObservation", + "capture_electron_scenario", + "rea-playwright-electron-active", + ], + [ + "javascriptRuntimeObservation", + "list_javascript_runtime_targets", + "rea-v8-inspector", + ], +] as const; + +const resources: Array<() => Promise> = []; +afterEach(async () => { + for (const close of resources.splice(0).reverse()) await close(); +}); + +const boot = async (factories: OptionalObservationFactories) => { + const loaded = await loadOptionalObservationProviders(factories); + let server: McpServer | undefined; + let session: BinarySessionPort | undefined; + let shutdown: (() => void) | undefined; + let resolveClose: (() => void) | undefined; + const closed = new Promise((resolve) => { + resolveClose = resolve; + }); + const output: string[] = []; + const exitCodes: number[] = []; + expect( + await run({ + env: {}, + loadOptionalProviders: async () => loaded, + createServer: (analysis, selectedSession, options) => { + session = selectedSession; + // A configured policy cannot hide an actual adapter loading failure. + return createServer(analysis, selectedSession, { + ...options, + availabilityPolicy: () => ({ + processCaptureEnabled: true, + browserObservationEnabled: true, + browserScenarioEnabled: true, + electronObservationEnabled: true, + electronAutomationEnabled: true, + v8InspectorObservationEnabled: true, + }), + }); + }, + serve: (factory) => { + const created = factory({ era: "legacy" }); + if (!(created instanceof McpServer)) + throw new Error("Expected the production synchronous MCP factory"); + server = created; + return { + close: async () => { + await server?.close(); + resolveClose?.(); + }, + }; + }, + registerShutdown: (handler) => { + shutdown = handler; + return () => {}; + }, + writeStderr: (message) => output.push(message), + setExitCode: (code) => exitCodes.push(code), + }), + ).toBe(0); + expect(output).toEqual([]); + if (server === undefined || session === undefined || shutdown === undefined) + throw new Error("Runtime did not construct the MCP server and session"); + const close = shutdown; + resources.push(async () => { + close(); + await closed; + await new Promise((resolve) => setImmediate(resolve)); + expect(exitCodes).toEqual([]); + }); + return { server, session, loaded }; +}; + +mcpTest.for(failedAdapters)( + "isolates %s and retains successful peers", + async ([port, operation, providerId], { mcp }) => { + const calls: string[] = []; + const failure = `${port} synthetic import failure`; + const factories = optionalObservationFactories(calls); + const connected = await boot({ + ...factories, + [port]: async () => { + throw new Error(failure); + }, + }); + expect(connected.loaded.optionalProviderLoadFailures).toEqual({ + [port]: { providerId, reason: failure }, + }); + for (const [peer] of failedAdapters) + expect(connected.loaded[peer] !== undefined).toBe(peer !== port); + const client = await mcp.connect(connected.server); + const names = (await client.listTools()).tools.map(({ name }) => name); + expect(new Set(names)).toEqual( + new Set(TOOL_CONTRACTS.map(({ name }) => name)), + ); + expect(names).toHaveLength(TOOL_CONTRACTS.length); + const example = toolContract(operation).examples[0]; + if (example === undefined) + throw new Error(`Missing example for ${operation}`); + const unavailable = await client.callTool({ + name: operation, + arguments: example.input, + }); + expect(unavailable.isError).toBe(true); + expect(unavailable.structuredContent).toMatchObject({ + error: { + details: { + provider_id: providerId, + operation, + reason: `Adapter could not load: ${failure}`, + }, + }, + }); + const status = await client.callTool({ + name: "binary_session", + arguments: {}, + }); + expect(status.structuredContent).toMatchObject({ + result: { + tool_availability: expect.arrayContaining([ + expect.objectContaining({ + name: operation, + available: false, + reason: "provider_unavailable", + remediation: expect.stringContaining(failure), + }), + expect.objectContaining({ + name: "analyze_javascript_application", + available: true, + }), + expect.objectContaining({ + name: "reconcile_javascript_runtime", + available: true, + }), + expect.objectContaining({ + name: + port === "electronActiveObservation" + ? "list_electron_targets" + : "capture_electron_scenario", + available: true, + }), + ]), + }, + }); + const healthy = + port === "javascriptRuntimeObservation" + ? "list_browser_targets" + : "list_javascript_runtime_targets"; + const listed = await client.callTool({ + name: healthy, + arguments: + healthy === "list_browser_targets" + ? { cdp_endpoint: "http://127.0.0.1:9222" } + : { inspector_endpoint: "http://127.0.0.1:9222" }, + }); + expect(listed.isError).not.toBe(true); + const evidenceId = z + .object({ evidence: z.object({ evidence_id: z.string() }) }) + .parse(listed.structuredContent).evidence.evidence_id; + expect(connected.session.evidenceById(evidenceId)?.operation).toBe(healthy); + expect(calls).toEqual([ + port === "javascriptRuntimeObservation" ? "browser" : "runtime", + ]); + const reconciled = await client.callTool({ + name: "reconcile_javascript_runtime", + arguments: toolContract("reconcile_javascript_runtime").examples[0] + ?.input, + }); + expect(reconciled.isError).not.toBe(true); + if ( + port === "electronObservation" || + port === "electronActiveObservation" + ) { + const directory = await createTestTempDirectory("rea-optional-static-"); + await writeFile( + join(directory, "package.json"), + JSON.stringify({ name: "optional-isolation", main: "index.js" }), + ); + await writeFile(join(directory, "index.js"), "exports.answer = 42;\n"); + const analyzed = await client.callTool({ + name: "analyze_javascript_application", + arguments: { input_path: directory, format: "directory" }, + }); + expect(analyzed.isError).not.toBe(true); + expect(analyzed.structuredContent).toMatchObject({ + evidence: { operation: "analyze_javascript_application" }, + }); + } + const malformed = await client.callTool({ + name: operation, + arguments: { unexpected: true }, + }); + expect(malformed.isError).toBe(true); + expect(JSON.stringify(malformed)).not.toContain(failure); + }, +); + +mcpTest( + "starts with every optional adapter failed and creates fresh connection state", + async ({ mcp }) => { + const failed = async (): Promise => { + throw new Error("all optional adapters unavailable"); + }; + const first = await boot({ + browserObservation: failed, + browserScenarioCapture: failed, + electronObservation: failed, + electronActiveObservation: failed, + javascriptRuntimeObservation: failed, + }); + const client = await mcp.connect(first.server); + expect((await client.listTools()).tools).toHaveLength( + TOOL_CONTRACTS.length, + ); + const status = await client.callTool({ + name: "binary_session", + arguments: {}, + }); + expect(status.isError).not.toBe(true); + const second = await boot(optionalObservationFactories([])); + expect(second.loaded.optionalProviderLoadFailures).toEqual({}); + expect(second.session.exportEvidenceBundle().records).toEqual([]); + expect(second.loaded.browserObservation).toBeDefined(); + }, +); diff --git a/tests/evaluation/moduleBoundaries.test.ts b/tests/evaluation/moduleBoundaries.test.ts index 8796c4873..65d316b81 100644 --- a/tests/evaluation/moduleBoundaries.test.ts +++ b/tests/evaluation/moduleBoundaries.test.ts @@ -16,6 +16,9 @@ describe("incremental module import boundaries", () => { ["src/application/probe.ts", "../android/JadxProvider.js", false], ["src/application/probe.ts", "../composition/android.js", false], ["src/server/probe.ts", "../firmware/FirmwareProvider.js", false], + ["src/server/probe.ts", "../browser/CdpBrowserProvider.js", false], + ["src/application/probe.ts", "../browser/V8InspectorProvider.js", false], + ["src/composition/probe.ts", "../browser/CdpBrowserProvider.js", true], ["src/domain/probe.ts", "./result.js", true], ["src/contracts/probe.ts", "../domain/result.js", true], ["src/application/probe.ts", "../domain/result.js", true], diff --git a/tests/fixtures/optionalObservationProviders.ts b/tests/fixtures/optionalObservationProviders.ts new file mode 100644 index 000000000..2122380c1 --- /dev/null +++ b/tests/fixtures/optionalObservationProviders.ts @@ -0,0 +1,76 @@ +import type { BrowserObservationPort } from "../../src/application/BrowserObservationPort.js"; +import type { JavaScriptRuntimeObservationPort } from "../../src/application/JavaScriptRuntimeObservationPort.js"; +import { + CDP_BROWSER_PROVIDER_IDENTITY, + V8_INSPECTOR_PROVIDER_IDENTITY, +} from "../../src/browser/providerIdentities.js"; +import { createBrowserScenarioProvider } from "../../src/composition/browserScenario.js"; +import { createElectronObservationProvider } from "../../src/composition/electronObservation.js"; +import { createElectronScenarioProvider } from "../../src/composition/electronScenario.js"; +import type { OptionalObservationFactories } from "../../src/composition/optionalObservationProviders.js"; +import { AnalysisCapabilityUnavailableError } from "../../src/domain/analysisErrorCore.js"; +import { err, ok } from "../../src/domain/result.js"; + +const unselected = async () => + err( + new AnalysisCapabilityUnavailableError( + "recording-fixture", + "unselected", + "This fixture implements target listing only", + ), + ); + +/** Recording ports exercise application composition without claiming real engines. */ +export const optionalObservationFactories = ( + calls: string[], +): OptionalObservationFactories => { + const browser: BrowserObservationPort = { + identity: () => CDP_BROWSER_PROVIDER_IDENTITY, + listTargets: async () => { + calls.push("browser"); + return ok({ + browser: { + product: "Recording browser", + protocol_version: "1.3", + revision: "fixture", + user_agent: "fixture", + js_version: "fixture", + }, + targets: [], + excluded: { disallowed_origin: 0, unsupported_url: 0, non_page: 0 }, + limitations: ["Recording port; no real browser was connected."], + }); + }, + inspectPage: unselected, + analyzeBundle: unselected, + observeSession: unselected, + discoverWebMcpTools: unselected, + compareCaptures: unselected, + captureScreenshot: unselected, + compareScreenshots: unselected, + }; + const runtime: JavaScriptRuntimeObservationPort = { + identity: () => V8_INSPECTOR_PROVIDER_IDENTITY, + listTargets: async () => { + calls.push("runtime"); + return ok({ + runtime: { + product: "Recording Node.js", + protocol_version: "1.0", + v8_version: null, + }, + targets: [], + excluded: { unsupported_location: 0, unconnectable: 0 }, + limitations: ["Recording port; no real Inspector was connected."], + }); + }, + observe: unselected, + }; + return { + browserObservation: async () => browser, + browserScenarioCapture: async () => createBrowserScenarioProvider(), + electronObservation: async () => createElectronObservationProvider(), + electronActiveObservation: async () => createElectronScenarioProvider(), + javascriptRuntimeObservation: async () => runtime, + }; +};