diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts index bd090f709..f2721a083 100644 --- a/src/application/javascript/ElectronBoundaryAnalysis.ts +++ b/src/application/javascript/ElectronBoundaryAnalysis.ts @@ -3,7 +3,10 @@ import type { ElectronIpcFinding } from "../../domain/javascript/electronStaticA import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import type { JavaScriptModuleArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; -import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js"; +import { + resolveArtifactPathByContext, + type ResolveArtifactPathInput, +} from "./JavaScriptArtifactPathResolution.js"; /** One IPC fact retaining its exact owning artifact for graph projection. */ export interface ElectronIpcRecord { @@ -160,6 +163,7 @@ export const summarizeElectronBoundaries = ( specifier: finding.module_path, context: finding.module_resolution_context, files, + runtimePathKind: "node-entrypoint", }), ).length, native_addon_bindings: nativeBindings.length, @@ -204,6 +208,7 @@ interface ResolveToFileInput { readonly files: ReadonlyMap; readonly kind?: JavaScriptArtifactFile["kind"]; readonly moduleKind?: "import" | "require"; + readonly runtimePathKind?: ResolveArtifactPathInput["runtimePathKind"]; } const resolvesToFile = (input: ResolveToFileInput): boolean => { @@ -212,6 +217,9 @@ const resolvesToFile = (input: ResolveToFileInput): boolean => { sourcePath: input.file.path, context: input.context, files: input.files, + ...(input.runtimePathKind === undefined + ? {} + : { runtimePathKind: input.runtimePathKind }), ...(input.moduleKind === undefined ? {} : { moduleKind: input.moduleKind }), }).resolved_path; return ( diff --git a/src/application/javascript/ElectronBoundaryGraphWindows.ts b/src/application/javascript/ElectronBoundaryGraphWindows.ts index 63f62bd12..2ffe550c6 100644 --- a/src/application/javascript/ElectronBoundaryGraphWindows.ts +++ b/src/application/javascript/ElectronBoundaryGraphWindows.ts @@ -23,7 +23,10 @@ import { electronFindingSourceNode, electronRangeKey, } from "./ElectronBoundaryGraphContext.js"; -import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js"; +import { + artifactPathResolutionProperties, + resolveArtifactPathByContext, +} from "./JavaScriptArtifactPathResolution.js"; interface FindingInput { readonly context: JavaScriptArtifactGraphContext; @@ -137,6 +140,7 @@ const addWindowPreload = ( sourcePath: input.file.path, context: resolutionContext, files: input.context.filesByPath, + runtimePathKind: "exact-file", }); const role = createElectronRoleNode(input.context, { kind: "electron-preload", @@ -160,6 +164,7 @@ const addWindowPreload = ( resolved_path: resolution.resolved_path, resolution_status: resolution.resolution_status, limitations: resolution.limitations, + ...artifactPathResolutionProperties(resolution), }, }); linkElectronRoleToAsset(input.context, { @@ -253,6 +258,16 @@ const addUtilityProcess = ( }, ], }); + const resolution = + value.module_path === null + ? undefined + : resolveArtifactPathByContext({ + declaredPath: value.module_path, + sourcePath: file.path, + context: value.module_resolution_context, + files: context.filesByPath, + runtimePathKind: "node-entrypoint", + }); addElectronInferenceEdge(context, { source: input.source, target: utility, @@ -264,15 +279,19 @@ const addUtilityProcess = ( properties: { declared_path: value.module_path, expression: value.module_expression, + ...(resolution === undefined + ? {} + : { + resolution_context: resolution.resolution_context, + resolved_path: resolution.resolved_path, + resolution_status: resolution.resolution_status, + limitations: resolution.limitations, + ...artifactPathResolutionProperties(resolution), + }), }, }); if (value.module_path === null) return; - const resolution = resolveArtifactPathByContext({ - declaredPath: value.module_path, - sourcePath: file.path, - context: value.module_resolution_context, - files: context.filesByPath, - }); + if (resolution === undefined) return; const resolved = resolution.resolved_path; const target = resolved === null diff --git a/src/application/javascript/JavaScriptArtifactGraphContext.ts b/src/application/javascript/JavaScriptArtifactGraphContext.ts index 1779bdaae..1070c8ae0 100644 --- a/src/application/javascript/JavaScriptArtifactGraphContext.ts +++ b/src/application/javascript/JavaScriptArtifactGraphContext.ts @@ -22,6 +22,7 @@ import { unavailableAstEvidence, } from "./JavaScriptArtifactGraphEvidence.js"; import { + artifactPathResolutionProperties, resolveArtifactPathByContext, type ArtifactPathResolution, } from "./JavaScriptArtifactPathResolution.js"; @@ -273,6 +274,7 @@ export const createElectronRoleNode = ( resolved_path: input.resolution.resolved_path, resolution_status: input.resolution.resolution_status, limitations: input.resolution.limitations, + ...artifactPathResolutionProperties(input.resolution), mechanism: input.mechanism, }, evidence: staticInferenceEvidence({ diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 0020ef577..5e939d0f9 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -283,6 +283,14 @@ const addDiscoveredRole = ( sourcePath: input.file.path, context: input.value.resolution_context, files: context.filesByPath, + ...(input.value.role === "preload" || + (input.value.role === "renderer" && + input.value.resolution_context === "filesystem-expression") + ? { runtimePathKind: "exact-file" as const } + : input.value.role === "renderer" && + input.value.resolution_context === "module-specifier" + ? { runtimePathKind: "url-resource" as const } + : {}), }); const role = createElectronRoleNode(context, { kind: diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index b746d7e97..6f40767ef 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -1318,6 +1318,29 @@ describe("package import alias resolution", () => { }); }); + it("keeps package import aliases available for runtime entrypoints", () => { + const files = aliasFiles({ + "package.json": JSON.stringify({ + imports: { "#worker": "./worker.js" }, + }), + "main.cjs": "", + "worker.js": "", + }); + expect( + resolve({ + declaredPath: "#worker", + sourcePath: "main.cjs", + context: "module-specifier", + files, + runtimePathKind: "exact-file", + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "worker.js", + declared_path: "#worker", + }); + }); + it("uses a nested package imports map rather than the application root", () => { const files = aliasFiles({ "package.json": JSON.stringify({ @@ -2093,6 +2116,26 @@ describe("url-reference directory and empty-segment semantics", () => { }); }); +describe("suffix-only non-runtime URL references", () => { + it.each(["", "?cache=1", "#worker"])( + "rejects URL reference %s without a path or known base document", + (declaredPath) => { + expect( + resolve({ + declaredPath, + sourcePath: "dist/app.mjs", + context: "url-reference", + files: fileMap([file("dist/app.mjs", "root")]), + }), + ).toMatchObject({ + declared_path: declaredPath, + resolution_status: "rejected", + resolved_path: null, + }); + }, + ); +}); + describe("url-reference JavaScript artifact path resolution", () => { it.each([ ["C|?cache=1#entry", "dist/C|"], @@ -2327,3 +2370,178 @@ describe("exact HTML resource resolution", () => { }); }); }); + +describe("exact Electron runtime entrypoint resolution", () => { + it("requires a protocol for Electron URL resources", () => { + const files = fileMap([ + file("app/main.cjs", "root"), + file("app/renderer", "root"), + file("app/renderer.js", "root"), + ]); + expect( + resolve({ + declaredPath: "./renderer", + sourcePath: "app/main.cjs", + context: "module-specifier", + files, + runtimePathKind: "url-resource", + }), + ).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + limitations: [expect.stringContaining("requires a URL protocol")], + }); + expect( + resolve({ + declaredPath: "file:///app/renderer", + sourcePath: "app/main.cjs", + context: "module-specifier", + files, + runtimePathKind: "url-resource", + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/renderer", + }); + }); + + it("checks local paths exactly while preserving bare package lookup", () => { + const files = fileMap([ + file("app/main.cjs", "root"), + file("app/worker.js", "root"), + file("app/worker", "root"), + file("app/worker#v1.js", "root"), + file( + "node_modules/exported/package.json", + "root", + '{"exports":{"./parser":"./parser.cjs"}}', + ), + file("node_modules/exported/parser.cjs", "root"), + file("app/fallback/index.js", "root"), + ]); + expect( + resolve({ + declaredPath: "./worker.js", + sourcePath: "app/main.cjs", + context: "module-specifier", + files, + runtimePathKind: "exact-file", + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/worker.js", + }); + expect( + resolve({ + declaredPath: "exported/parser", + sourcePath: "app/main.cjs", + context: "module-specifier", + files, + runtimePathKind: "exact-file", + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "node_modules/exported/parser.cjs", + }); + expect( + resolve({ + declaredPath: "./worker#v1.js", + sourcePath: "app/main.cjs", + context: "module-specifier", + files, + runtimePathKind: "exact-file", + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/worker#v1.js", + }); + expect( + resolve({ + declaredPath: "./fallback", + sourcePath: "app/main.cjs", + context: "module-specifier", + files, + runtimePathKind: "exact-file", + }), + ).toMatchObject({ + resolution_status: "not-found", + resolved_path: null, + }); + }); + + it("accepts an exact extensionless file but not a directory index", () => { + const files = fileMap([ + file("app/main.cjs", "root"), + file("app/preload", "root"), + file("app/preload.js", "root"), + file("app/fallback/index.js", "root"), + ]); + expect( + resolve({ + declaredPath: "./preload", + sourcePath: "app/main.cjs", + context: "module-specifier", + files, + runtimePathKind: "exact-file", + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/preload", + }); + expect( + resolve({ + declaredPath: "./fallback", + sourcePath: "app/main.cjs", + context: "module-specifier", + files, + runtimePathKind: "exact-file", + }), + ).toMatchObject({ + resolution_status: "not-found", + resolved_path: null, + }); + }); +}); + +it.each(["#home", "?theme=dark", ""])( + "does not invent a document base for runtime URL %s", + (declaredPath) => { + expect( + resolve({ + declaredPath, + sourcePath: "main.cjs", + context: "url-reference", + runtimePathKind: "url-resource", + files: fileMap([file("main.cjs", "root")]), + }), + ).toMatchObject({ + declared_path: declaredPath, + resolution_status: "unavailable", + resolved_path: null, + }); + }, +); + +it("resolves Node runtime entrypoints without substituting emitted TypeScript sources", () => { + const files = fileMap([ + file("main.ts", "root"), + file("worker.ts", "root"), + file("worker.js", "other-container"), + file("runtime.js", "root"), + file("directory/index.js", "root"), + ]); + for (const [declaredPath, status, path] of [ + ["./worker.js", "not-found", null], + ["./runtime", "resolved", "runtime.js"], + ["./directory", "resolved", "directory/index.js"], + ] as const) + expect( + resolve({ + declaredPath, + sourcePath: "main.ts", + context: "module-specifier", + files, + runtimePathKind: "node-entrypoint", + }), + ).toMatchObject({ resolution_status: status, resolved_path: path }); +}); diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index 88aa1508f..04172cf65 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -78,6 +78,8 @@ export interface ResolveArtifactPathInput { readonly htmlBaseHref?: string | null; /** Import/require semantics; runtime resource paths omit this provenance. */ readonly moduleKind?: JavaScriptModuleResolutionMode; + /** Runtime paths retain their loader owner independently of source imports. */ + readonly runtimePathKind?: "exact-file" | "node-entrypoint" | "url-resource"; } /** Preserve an explicitly retained loader mode on graph relationships. */ @@ -189,6 +191,8 @@ const conditionalResolution = ( }); const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => { + if (input.runtimePathKind === "node-entrypoint") + return resolveNodeRuntimeEntrypoint(input); if (input.moduleKind === "compiler-dependent") { const imported = resolvePath({ ...input, moduleKind: "import" }); const required = resolvePath({ ...input, moduleKind: "require" }); @@ -222,6 +226,10 @@ const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => { ], }; } + if (input.runtimePathKind === "url-resource") + return resolveExactUrlResource(input); + if (input.runtimePathKind === "exact-file" && isRuntimePathDeclaration(input)) + return resolveRuntimeEntrypoint(input); // Package import keys are literal lookup identities, not artifact paths. // The selected target is admitted separately by resolveExportTarget. if ( @@ -260,6 +268,82 @@ const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => { return resolveCandidate(input, confined, new Set(), emittedSourceCandidates); }; +const resolveExactUrlResource = ( + input: ResolveArtifactPathInput, +): CandidateResolution => { + // Electron loadURL requires a protocol. A scheme-less string is not relative + // to the calling script; filesystem paths belong to loadFile. + if (!hasScheme(input.declaredPath)) + return unresolvedCandidate("unavailable", [ + "Electron loadURL requires a URL protocol; relative filesystem paths belong to loadFile.", + ]); + if (!/^file:/iu.test(input.declaredPath)) + return unresolvedCandidate("external", [ + "Only file URLs can be mapped to selected local application artifacts.", + ]); + const urlContextCandidate = contextualCandidate(input); + if (typeof urlContextCandidate !== "string") return urlContextCandidate; + const confined = confineCandidate(urlContextCandidate); + if (typeof confined !== "string") return confined; + return ( + resolveFileCandidates(input, [confined]) ?? { + resolvedPath: null, + status: "not-found", + candidatePath: confined, + limitations: [ + `The exact URL resource ${confined} was not found among the selected application files.`, + ], + } + ); +}; + +const isRuntimePathDeclaration = (input: ResolveArtifactPathInput): boolean => + input.context === "filesystem-expression" || + (input.context === "module-specifier" && + (isRelativeJavaScriptModuleSpecifier(input.declaredPath) || + input.declaredPath.startsWith("/") || + fileUrlPath(input.declaredPath) !== undefined)); + +const resolveNodeRuntimeEntrypoint = ( + input: ResolveArtifactPathInput, +): CandidateResolution => { + const { runtimePathKind: _runtimePathKind, ...sourceInput } = input; + const runtimeInput = { ...sourceInput, moduleKind: "require" as const }; + if (!isRuntimePathDeclaration(runtimeInput)) return resolvePath(runtimeInput); + const candidate = contextualCandidate(runtimeInput); + if (typeof candidate !== "string") return candidate; + const confined = confineCandidate(candidate); + if (typeof confined !== "string") return confined; + // Node entrypoints use runtime file/directory probes. They do not originate + // in emitted TypeScript import syntax and cannot select a source substitute. + return resolveCandidate(runtimeInput, confined, new Set()); +}; + +const resolveRuntimeEntrypoint = ( + input: ResolveArtifactPathInput, +): CandidateResolution => { + const rejected = rejectDeclaration(input.declaredPath); + if (rejected !== null) return rejected; + if (hasScheme(input.declaredPath) || looksExternal(input.declaredPath)) + return unresolvedCandidate("external", [ + "Electron runtime entrypoints must identify local paths, not external URLs.", + ]); + const candidate = confineCandidate( + relativeCandidate(input.sourcePath, input.declaredPath), + ); + if (typeof candidate !== "string") return candidate; + return ( + resolveFileCandidates(input, [candidate]) ?? { + resolvedPath: null, + status: "not-found", + candidatePath: candidate, + limitations: [ + `The declared Electron runtime entrypoint ${candidate} was not found as an exact file among the selected application files; extension and directory fallback were not attempted.`, + ], + } + ); +}; + const resolveRuntimeSourceCandidates = ( input: ResolveArtifactPathInput, exactPath: string, @@ -316,6 +400,14 @@ const contextualCandidate = ( context === "url-reference" ? stripQueryAndFragment(input.declaredPath) : input.declaredPath; + if (context === "url-reference" && declared.length === 0) + return input.runtimePathKind === "url-resource" + ? unresolvedCandidate("unavailable", [ + "A suffix-only runtime URL requires the window's current document URL; the calling script does not establish that base.", + ]) + : unresolvedCandidate("rejected", [ + "The URL reference contains no pathname, and its base document is not established by this analysis.", + ]); if (context === "module-specifier" && /^file:/iu.test(declared)) { const fileUrl = fileUrlPath(declared); if (fileUrl === null) diff --git a/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts b/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts index 6d9b62a63..977083c76 100644 --- a/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts +++ b/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts @@ -390,6 +390,9 @@ cliTest( ]), ), "dynamic-base.mjs": 'new Worker(new URL("./worker.js", base));', + "empty-worker-url.mjs": 'new Worker("");', + "query-only-worker-url.mjs": 'new Worker("?cache=1");', + "fragment-only-worker-url.mjs": 'new Worker("#worker");', "shadowed.mjs": 'function start(URL) { return new Worker(new URL("./worker.js", import.meta.url)); }', "typed-shadowed.ts": @@ -428,6 +431,9 @@ cliTest( "directory-dot.mjs": "./worker.js/.", "directory-parent.mjs": "./worker.js/child/..", "empty-segment.mjs": "./child//../worker.js", + "empty-worker-url.mjs": "", + "query-only-worker-url.mjs": "?cache=1", + "fragment-only-worker-url.mjs": "#worker", "package.mjs": "./package-worker", "external.mjs": "https://example.test/worker.js", }; diff --git a/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts b/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts index 795075c99..c2eb1070e 100644 --- a/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts +++ b/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts @@ -67,6 +67,11 @@ describe("static Electron application analysis", () => { }); }); + it( + "keeps Electron runtime entrypoints on their declared filenames", + exactRuntimeEntrypointFixture, + ); + it("keeps computed identifier member keys unknown", async () => { const root = await computedMemberFixtureDirectory(); @@ -153,9 +158,210 @@ describe("static Electron application analysis", () => { }); }); +const exactRuntimeEntrypointFixture = async (): Promise => { + const root = await createTestTempDirectory("rea-electron-exact-entrypoints-"); + await mkdir(join(root, "renderer/page"), { recursive: true }); + await Promise.all([ + writeFile( + join(root, "main.cjs"), + String.raw` +const { BrowserWindow, utilityProcess } = require("electron"); +const path = require("node:path"); +new BrowserWindow({ webPreferences: { preload: "./entry" } }); +new BrowserWindow().loadURL("file:///renderer/index.html"); +new BrowserWindow().loadURL("renderer/bare.html"); +new BrowserWindow().loadURL("#home"); +new BrowserWindow().loadURL(""); +new BrowserWindow().loadURL("./renderer/other.html?theme=dark#home"); +new BrowserWindow().loadURL("./renderer/missing.html"); +new BrowserWindow().loadFile("./renderer/page"); +new BrowserWindow({ webPreferences: { preload: path.join(__dirname, "preload") } }); +new BrowserWindow({ webPreferences: { preload: "./explicit-preload.js" } }); +utilityProcess.fork("./worker"); +utilityProcess.fork("./worker#v1.js"); +utilityProcess.fork("./service.js"); +`, + ), + writeFile(join(root, "entry.html"), "sibling"), + writeFile(join(root, "preload.html"), "sibling"), + writeFile(join(root, "worker.js"), "module.exports = {};"), + writeFile(join(root, "renderer/index.html"), "renderer"), + writeFile( + join(root, "renderer/bare.html"), + "bare renderer URL", + ), + writeFile(join(root, "renderer/other.html"), "other"), + writeFile(join(root, "renderer/missing.html.js"), "module.exports = {};"), + writeFile( + join(root, "renderer/page/index.html"), + "directory", + ), + writeFile(join(root, "explicit-preload.js"), "module.exports = {};"), + writeFile(join(root, "service.js"), "module.exports = {};"), + ]); + + const result = await reconstructJavaScriptArtifact({ input_path: root }); + const graph = parseJavaScriptApplicationGraph(result.graph); + expectSuffixOnlyRenderersUnresolved(graph); + expectExactRendererPaths(graph); + const roles = graph.nodes.filter(({ kind }) => kind === "electron-preload"); + const roleByDeclaration = new Map( + roles.map((role) => [role.observations[0]?.properties.declared_path, role]), + ); + + expect(result.electron_summary.resolved_utility_entrypoints).toBe(2); + for (const [declaredPath, candidatePath] of [ + ["./entry", "entry"], + ["preload", "preload"], + ]) { + const role = roleByDeclaration.get(declaredPath); + expect(role?.observations[0]?.properties).toMatchObject({ + declared_path: declaredPath, + resolved_path: null, + resolution_status: "not-found", + candidate_path: candidatePath, + limitations: [ + expect.stringContaining( + "extension and directory fallback were not attempted", + ), + ], + }); + expect( + graph.edges.some( + (edge) => + edge.source_node_id === role?.node_id && edge.relation === "maps_to", + ), + ).toBe(false); + } + expect( + roleByDeclaration.get("./explicit-preload.js")?.observations[0]?.properties, + ).toMatchObject({ + resolved_path: "explicit-preload.js", + resolution_status: "resolved", + }); + + const utilityLaunches = graph.edges.filter( + (edge) => + edge.evidence.extractor.operation === "map-utility-process-launch", + ); + const resolvedWorker = utilityLaunches.find( + (edge) => edge.properties.declared_path === "./worker", + ); + expect(resolvedWorker?.evidence).toMatchObject({ + authority: "static-relationship-inference", + state: "inferred", + limitations: expect.arrayContaining([ + "Static syntax does not prove that this relationship executes at runtime.", + ]), + }); + expect(utilityLaunches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + declared_path: "./worker", + resolved_path: "worker.js", + resolution_status: "resolved", + }), + }), + expect.objectContaining({ + properties: expect.objectContaining({ + declared_path: "./worker#v1.js", + resolved_path: null, + resolution_status: "not-found", + }), + }), + expect.objectContaining({ + properties: expect.objectContaining({ + declared_path: "./service.js", + resolved_path: "service.js", + resolution_status: "resolved", + }), + }), + ]), + ); + expect( + graph.edges.some( + (edge) => + edge.evidence.extractor.operation === + "resolve-utility-process-entrypoint" && + edge.properties.resolved_path === "worker.html", + ), + ).toBe(false); +}; + type Reconstruction = Awaited>; type ApplicationGraph = ReturnType; +const expectSuffixOnlyRenderersUnresolved = (graph: ApplicationGraph): void => { + const renderers = new Map( + graph.nodes + .filter(({ kind }) => kind === "electron-renderer") + .map((renderer) => [ + renderer.observations[0]?.properties.declared_path, + renderer, + ]), + ); + expect( + renderers.get("file:///renderer/index.html")?.observations[0]?.properties, + ).toMatchObject({ + resolved_path: "renderer/index.html", + resolution_status: "resolved", + }); + for (const declaredPath of [ + "./renderer/other.html?theme=dark#home", + "renderer/bare.html", + "./renderer/missing.html", + "#home", + "", + ]) { + const renderer = renderers.get(declaredPath); + expect(renderer?.observations[0]?.properties).toMatchObject({ + resolved_path: null, + resolution_status: "unavailable", + }); + expect(renderer?.observations[0]?.properties.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("requires a URL protocol"), + ]), + ); + expect( + graph.edges.some( + (edge) => + edge.source_node_id === renderer?.node_id && + edge.relation === "maps_to", + ), + ).toBe(false); + } +}; + +const expectExactRendererPaths = (graph: ApplicationGraph): void => { + const renderers = new Map( + graph.nodes + .filter(({ kind }) => kind === "electron-renderer") + .map((renderer) => [ + renderer.observations[0]?.properties.declared_path, + renderer, + ]), + ); + for (const [declaredPath, candidatePath] of [ + ["./renderer/page", "renderer/page"], + ]) { + const renderer = renderers.get(declaredPath); + expect(renderer?.observations[0]?.properties).toMatchObject({ + resolved_path: null, + resolution_status: "not-found", + candidate_path: candidatePath, + }); + expect( + graph.edges.some( + (edge) => + edge.source_node_id === renderer?.node_id && + edge.relation === "maps_to", + ), + ).toBe(false); + } +}; + const expectElectronSummary = (result: Reconstruction): void => { expect(result.electron_summary).toMatchObject({ browser_windows: 3,