diff --git a/src/domain/javascript/electronNativeLoaderPaths.test.ts b/src/domain/javascript/electronNativeLoaderPaths.test.ts index f05bc39aff..c56a066c16 100644 --- a/src/domain/javascript/electronNativeLoaderPaths.test.ts +++ b/src/domain/javascript/electronNativeLoaderPaths.test.ts @@ -177,3 +177,33 @@ it("does not prove native bindings through a replaced aliased loader member", () ); expect(analysis.electron.native_addon_bindings).toEqual([]); }); + +it.each( + ["node", "NODE", "Node"].flatMap((extension) => + ["require", "import", "commonjs-export", "esm-export"].map((kind) => ({ + extension, + kind, + })), + ), +)( + "preserves native extension dispatch for $kind with .$extension", + ({ extension, kind }) => { + const specifier = `./AddOn-É.${extension}`; + const source = + kind === "require" + ? `const addon = require(${JSON.stringify(specifier)});` + : kind === "import" + ? `import addon from ${JSON.stringify(specifier)};` + : kind === "commonjs-export" + ? `module.exports = require(${JSON.stringify(specifier)});` + : `export { default } from ${JSON.stringify(specifier)};`; + const analysis = + kind === "require" || kind === "commonjs-export" + ? analyzeCommonJsStaticFixture(source) + : analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.native_addon_bindings).toEqual( + extension === "node" ? [expect.objectContaining({ specifier })] : [], + ); + }, +); diff --git a/src/domain/javascript/electronStaticAnalysisNative.ts b/src/domain/javascript/electronStaticAnalysisNative.ts index 26306c8a3c..684b67a738 100644 --- a/src/domain/javascript/electronStaticAnalysisNative.ts +++ b/src/domain/javascript/electronStaticAnalysisNative.ts @@ -32,7 +32,6 @@ import { type CommonJsExportSite, } from "./javascriptCommonJsExportReceivers.js"; import type { JavaScriptReceiverIndex } from "./javascriptSemanticAnalysis.js"; -import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js"; interface NativeBindingInput { @@ -364,5 +363,6 @@ const isNativeSpecifier = ( ): boolean => { const path = moduleKind === "require" ? specifier : stripQueryAndFragment(specifier); - return path.toLowerCase().endsWith(".node"); + // Extension dispatch uses exact spelling, independently of filesystem casing. + return path.endsWith(".node"); }; diff --git a/tests/boundary/javascript/nativeTypeBindings.test.ts b/tests/boundary/javascript/nativeTypeBindings.test.ts index 5f22a18157..783dc9f9ee 100644 --- a/tests/boundary/javascript/nativeTypeBindings.test.ts +++ b/tests/boundary/javascript/nativeTypeBindings.test.ts @@ -9,12 +9,12 @@ import { buildJavaScriptNativeHandoffs } from "../../../src/domain/javascript/ja import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -const analyzeFixture = async (source: string) => { +const analyzeFixture = async (source: string, addonPath = "addon.node") => { const fixture = await createTestTempDirectory("rea-native-types-"); const scope = new ArtifactResourceScope(); try { await writeFile(join(fixture, "main.ts"), `${source}\n`); - await writeFile(join(fixture, "addon.node"), ""); + await writeFile(join(fixture, addonPath), ""); const result = await analyzeJavaScriptApplication( { input_path: fixture, format: "directory" }, scope, @@ -88,3 +88,58 @@ it("does not request native-addon members from erased TypeScript names", async ( ?.observations[0]?.properties.requested_members, ).toEqual(["load"]); }, 30_000); + +it.each(["node", "NODE", "Node"])( + "keeps path spelling while classifying .%s native requests", + async (extension) => { + const path = `AddOn-É.${extension}`; + const specifier = `./${path}`; + const result = await analyzeFixture( + `import { load } from ${JSON.stringify(specifier)}; load();`, + path, + ); + expect(result.statistics.parse_failures).toBe(0); + expect(result.summary.native_addon_bindings).toBe( + extension === "node" ? 1 : 0, + ); + expect( + result.graph.nodes.filter((node) => node.kind === "native-export"), + ).toEqual( + extension === "node" + ? [ + expect.objectContaining({ + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + specifier, + resolved_path: path, + native_export_verification: "not-performed", + }), + }), + ]), + }), + ] + : [], + ); + expect(result.graph.edges).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ specifier, resolved_path: path }), + evidence: expect.objectContaining({ + extractor: expect.objectContaining({ + operation: "map-static-relationship", + }), + }), + }), + ); + expect( + result.graph.nodes + .filter((node) => node.kind === "native-addon") + .flatMap((node) => node.observations), + ).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ path }), + }), + ); + }, + 30_000, +);