diff --git a/src/domain/javascript/electronStaticAnalysisBrowser.ts b/src/domain/javascript/electronStaticAnalysisBrowser.ts index 3439e1a6cd..465fcf683b 100644 --- a/src/domain/javascript/electronStaticAnalysisBrowser.ts +++ b/src/domain/javascript/electronStaticAnalysisBrowser.ts @@ -102,10 +102,20 @@ const collectWebPreferences = ( } => { const preferences: ElectronWebPreference[] = []; let unknown = 0; - let preload: ElectronBrowserWindowPreload = { - preload_path: null, - preload_resolution_context: null, - }; + const effectivePreload = objectProperty(object, "preload"); + const preloadPath = + effectivePreload.status === "explicit" + ? staticPath(effectivePreload.property.value) + : undefined; + const preload: ElectronBrowserWindowPreload = + effectivePreload.status === "explicit" && preloadPath !== undefined + ? { + preload_path: preloadPath, + preload_resolution_context: staticPathResolutionContext( + effectivePreload.property.value, + ), + } + : { preload_path: null, preload_resolution_context: null }; for (const property of object.properties) { if (t.isSpreadElement(property)) { unknown += 1; @@ -133,11 +143,6 @@ const collectWebPreferences = ( continue; } const path = name === "preload" ? staticPath(property.value) : undefined; - if (path !== undefined) - preload = { - preload_path: path, - preload_resolution_context: staticPathResolutionContext(property.value), - }; const value: ElectronStaticValue = path === undefined ? electronStaticValue(source, property.value) diff --git a/tests/acceptance/applications/electronPreloadOverridesCli.test.ts b/tests/acceptance/applications/electronPreloadOverridesCli.test.ts new file mode 100644 index 0000000000..7997a7d8cb --- /dev/null +++ b/tests/acceptance/applications/electronPreloadOverridesCli.test.ts @@ -0,0 +1,174 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; +import { z } from "zod"; + +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const FIRST = 'preload: "./first.js"'; +const LAST = 'preload: "./last.js"'; +const scenarios = [ + { + preferences: `${FIRST}, preload: runtimePath`, + preloadPath: null, + overrideExpression: "runtimePath", + }, + { + preferences: `${FIRST}, ...runtimeOptions`, + preloadPath: null, + overrideExpression: "runtimeOptions", + }, + { + preferences: `${FIRST}, [runtimeKey]: runtimeValue`, + preloadPath: null, + overrideExpression: "[runtimeKey]: runtimeValue", + }, + { + preferences: `${FIRST}, get preload() { return runtimePath; }`, + preloadPath: null, + overrideExpression: "get preload() { return runtimePath; }", + }, + { + preferences: `${FIRST}, preload() { return runtimePath; }`, + preloadPath: null, + overrideExpression: "preload() { return runtimePath; }", + }, + { preferences: `${FIRST}, preload: false`, preloadPath: null }, + { + preferences: `preload: runtimePath, ${FIRST}`, + preloadPath: "./first.js", + }, + { preferences: `...runtimeOptions, ${FIRST}`, preloadPath: "./first.js" }, + { + preferences: `[runtimeKey]: runtimeValue, ${FIRST}`, + preloadPath: "./first.js", + }, + { preferences: `${FIRST}, ${LAST}`, preloadPath: "./last.js" }, + { + preferences: `${FIRST}, ["preload"]: "./last.js"`, + preloadPath: "./last.js", + }, + { + preferences: `${FIRST}, ["sandbox"]: true, "": runtimeValue`, + preloadPath: "./first.js", + }, +] as const; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "reports effective preload overrides through %s without executing source", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-preload-overrides-"); + const sourceLines = scenarios.map( + ({ preferences }) => + `new BrowserWindow({webPreferences:{${preferences}}});`, + ); + await Promise.all([ + writeFile(join(root, "main.js"), `${sourceLines.join("\n")}\n`), + writeFile(join(root, "first.js"), 'throw new Error("inert preload");\n'), + writeFile(join(root, "last.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(scenarios.length); + + for (const [index, scenario] of scenarios.entries()) { + 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 + 1 + ); + }); + const observation = window?.observations[0]; + expect.soft(observation, scenario.preferences).toMatchObject({ + properties: { + web_preferences_status: "object-literal", + preload_path: scenario.preloadPath, + preload_resolution_context: + scenario.preloadPath === null ? null : "module-specifier", + web_preferences: expect.arrayContaining([ + { + name: "preload", + value: { + status: "literal", + value: "./first.js", + expression: null, + }, + }, + ]), + }, + evidence: { + state: "observed", + location: { + available: true, + value: { + kind: "source-range", + source: "main.js", + start: { line: index + 1, column: 0 }, + end: { + line: index + 1, + column: (sourceLines[index]?.length ?? 0) - 1, + }, + }, + }, + }, + }); + if ("overrideExpression" in scenario) + expect.soft(observation?.properties.web_preferences).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: { + status: "dynamic", + value: null, + expression: scenario.overrideExpression, + }, + }), + ]), + ); + const associations = graph.edges.filter( + (edge) => + edge.source_node_id === window?.node_id && + edge.evidence.extractor.operation === + "associate-browser-window-preload", + ); + if (scenario.preloadPath === null) { + expect.soft(associations, scenario.preferences).toEqual([]); + } else { + expect.soft(associations, scenario.preferences).toMatchObject([ + { + relation: "loads", + properties: { + declared_path: scenario.preloadPath, + resolved_path: scenario.preloadPath.slice(2), + resolution_status: "resolved", + }, + }, + ]); + } + } + }, +);