diff --git a/src/domain/javascript/electronStaticAnalysisBrowser.ts b/src/domain/javascript/electronStaticAnalysisBrowser.ts index 8b2ce559d2..8e19ab9fe2 100644 --- a/src/domain/javascript/electronStaticAnalysisBrowser.ts +++ b/src/domain/javascript/electronStaticAnalysisBrowser.ts @@ -25,7 +25,10 @@ import { staticPath, staticPathResolutionContext, } from "./javascriptStaticAnalysisHelpers.js"; -import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; +import { + semanticStaticPropertyKey, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js"; /** Inspect BrowserWindow, contextBridge, and utility-process syntax. */ @@ -45,7 +48,11 @@ const inspectBrowserWindow = ( ): void => { const path = electronCalleePath(node.callee, context); if (path?.length !== 1 || path[0] !== "BrowserWindow") return; - const options = argumentNode(node.arguments[0]); + const argument = argumentNode(node.arguments[0]); + const options = + argument === undefined + ? undefined + : unwrapJavaScriptExpression(argument).node; const collected = collectWindowOptions(context.source, options); context.accumulator.unknownFindings += collected.unknown; const finding: ElectronBrowserWindowFinding = { @@ -79,17 +86,18 @@ const collectWindowOptions = ( readonly unknown: number; } => { const lookup = objectProperty(options, "webPreferences"); - if ( - lookup.status !== "explicit" || - !t.isObjectExpression(lookup.property.value) - ) + const preferences = + lookup.status === "explicit" + ? unwrapJavaScriptExpression(lookup.property.value).node + : undefined; + if (lookup.status !== "explicit" || !t.isObjectExpression(preferences)) return { status: lookup.status === "missing" ? "missing" : "dynamic", preferences: [], preload: { preload_path: null, preload_resolution_context: null }, unknown: lookup.status === "missing" ? 0 : 1, }; - return collectWebPreferences(source, lookup.property.value); + return collectWebPreferences(source, preferences); }; const collectWebPreferences = ( diff --git a/src/domain/javascript/electronStaticAnalysisOptions.test.ts b/src/domain/javascript/electronStaticAnalysisOptions.test.ts index f271e6ffc5..22b49000a7 100644 --- a/src/domain/javascript/electronStaticAnalysisOptions.test.ts +++ b/src/domain/javascript/electronStaticAnalysisOptions.test.ts @@ -3,6 +3,34 @@ import { describe, expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; describe("Electron option evidence", () => { + it.each([ + '({ webPreferences: { preload: "./preload.js", sandbox: true } } as const)', + '{ webPreferences: ({ preload: "./preload.js", sandbox: true } as const) }', + '{ webPreferences: { preload: "./preload.js" as const, sandbox: true } }', + ])("unwraps type-only wrappers around inline options: %s", (options) => { + const analysis = analyzeJavaScriptStaticSource(` + import { BrowserWindow } from "electron"; + new BrowserWindow(${options}); + `); + + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toMatchObject([ + { + options_status: "object-literal", + web_preferences_status: "object-literal", + preload_path: "./preload.js", + preload_resolution_context: "module-specifier", + web_preferences: [ + { + name: "preload", + value: { status: "literal", value: "./preload.js" }, + }, + { name: "sandbox", value: { status: "literal", value: true } }, + ], + }, + ]); + }); + it("keeps the final explicit preload and service name with unrelated keys", () => { const analysis = analyzeJavaScriptStaticSource(` const { BrowserWindow, utilityProcess } = require("electron"); diff --git a/tests/acceptance/applications/electronPreloadOverridesCli.test.ts b/tests/acceptance/applications/electronPreloadOverridesCli.test.ts index 9b0a3df2fb..6bcb99c3cc 100644 --- a/tests/acceptance/applications/electronPreloadOverridesCli.test.ts +++ b/tests/acceptance/applications/electronPreloadOverridesCli.test.ts @@ -175,3 +175,125 @@ cliTest.for(["CLI", "stdio MCP"] as const)( } }, ); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "preserves asserted inline options and unresolved overrides through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-asserted-window-options-"); + const scenarios = [ + { + sourceLine: + 'new BrowserWindow(({webPreferences:{preload:"./first.js",sandbox:true}} as const));', + expectedPreload: "./first.js", + preferencesStatus: "object-literal", + }, + { + sourceLine: + 'new BrowserWindow({webPreferences:({preload:"./first.js",sandbox:true} as const)});', + expectedPreload: "./first.js", + preferencesStatus: "object-literal", + }, + { + sourceLine: + 'new BrowserWindow({webPreferences:{preload:"./first.js" as const,sandbox:true}});', + expectedPreload: "./first.js", + preferencesStatus: "object-literal", + }, + { + sourceLine: + "new BrowserWindow(({webPreferences:getPreferences()} as const));", + expectedPreload: null, + preferencesStatus: "dynamic", + }, + { + sourceLine: + 'new BrowserWindow(({webPreferences:{preload:"./first.js",...runtimeOptions}} as const));', + expectedPreload: null, + preferencesStatus: "object-literal", + }, + ] as const; + const sourceLines = scenarios.map(({ sourceLine }) => sourceLine); + await Promise.all([ + writeFile( + join(root, "main.ts"), + `import { BrowserWindow } from "electron";\n${sourceLines.join("\n")}\n`, + ), + writeFile(join(root, "first.js"), 'throw new Error("inert preload");\n'), + ]); + + let document: unknown; + if (surface === "CLI") { + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--format", "json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + document = response.json; + } else { + const { call } = await connectLocalToolsMcp(); + const response = await call("analyze_javascript_application", { + input_path: root, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + const { graph } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(document).normalized_result; + const windows = graph.nodes.filter(({ kind }) => kind === "browser-window"); + expect(windows).toHaveLength(sourceLines.length); + + for (const [index, scenario] of scenarios.entries()) { + const { sourceLine } = scenario; + const window = windows.find((node) => { + const location = node.observations[0]?.evidence.location; + return ( + location?.available && + location.value.kind === "source-range" && + location.value.start.line === index + 2 + ); + }); + const observation = window?.observations[0]; + const known = scenario.expectedPreload !== null; + expect.soft(observation, sourceLine).toMatchObject({ + properties: { + options_status: "object-literal", + web_preferences_status: scenario.preferencesStatus, + preload_path: scenario.expectedPreload, + preload_resolution_context: known ? "module-specifier" : null, + }, + evidence: { + state: "observed", + location: { + available: true, + value: { + kind: "source-range", + source: "main.ts", + start: { line: index + 2, column: 0 }, + end: { line: index + 2, column: sourceLine.length - 1 }, + }, + }, + }, + }); + const associations = graph.edges.filter( + (edge) => + edge.source_node_id === window?.node_id && + edge.evidence.extractor.operation === + "associate-browser-window-preload", + ); + if (known) { + expect.soft(associations, sourceLine).toMatchObject([ + { + relation: "loads", + properties: { + declared_path: "./first.js", + resolved_path: "first.js", + }, + }, + ]); + } else { + expect.soft(associations, sourceLine).toEqual([]); + } + } + }, +);