using System.Text.Json; using StellaOps.Scanner.Analyzers.Lang.Node; using StellaOps.Scanner.Analyzers.Lang.Tests.Harness; namespace StellaOps.Scanner.Analyzers.Lang.Node.Tests.Node; /// /// Tests for entrypoint detection in Node packages including bin, exports, main, /// module, worker, electron, and shebang detection. /// public sealed class NodeEntrypointDetectionTests : IDisposable { private readonly string _tempDir; public NodeEntrypointDetectionTests() { _tempDir = Path.Combine(Path.GetTempPath(), "node-entrypoint-tests-" + Guid.NewGuid().ToString("N")[..8]); Directory.CreateDirectory(_tempDir); } public void Dispose() { try { if (Directory.Exists(_tempDir)) { Directory.Delete(_tempDir, recursive: true); } } catch { // Ignore cleanup errors } } private void WriteFile(string relativePath, string content) { var fullPath = Path.Combine(_tempDir, relativePath); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); File.WriteAllText(fullPath, content); } #region bin field tests [Fact] public async Task BinField_StringFormat_DetectsEntrypoint() { // Arrange var packageJson = new { name = "cli-pkg", version = "1.0.0", bin = "./cli.js" }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("cli.js", "// cli"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("cli.js", result); } [Fact] public async Task BinField_ObjectFormat_DetectsEntrypoints() { // Arrange var packageJson = new { name = "multi-cli-pkg", version = "1.0.0", bin = new { cmd1 = "./bin/cmd1.js", cmd2 = "./bin/cmd2.js" } }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("bin/cmd1.js", "// cmd1"); WriteFile("bin/cmd2.js", "// cmd2"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("bin/cmd1.js", result); Assert.Contains("bin/cmd2.js", result); } [Fact] public async Task BinField_ObjectFormat_IncludesBinNames() { // Arrange var packageJson = new { name = "named-cli-pkg", version = "1.0.0", bin = new { mycli = "./cli.js" } }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("cli.js", "// cli"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("mycli", result); } #endregion #region main/module field tests [Fact] public async Task MainField_DetectsEntrypoint() { // Arrange var packageJson = new { name = "lib-pkg", version = "1.0.0", main = "./dist/index.js" }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("dist/index.js", "// index"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("dist/index.js", result); } [Fact] public async Task ModuleField_DetectsEntrypoint() { // Arrange var packageJson = new { name = "esm-pkg", version = "1.0.0", module = "./dist/index.mjs" }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("dist/index.mjs", "// esm index"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("dist/index.mjs", result); } [Fact] public async Task BothMainAndModule_DetectsBothEntrypoints() { // Arrange var packageJson = new { name = "dual-pkg", version = "1.0.0", main = "./dist/index.cjs", module = "./dist/index.mjs" }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("dist/index.cjs", "// cjs"); WriteFile("dist/index.mjs", "// esm"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("dist/index.cjs", result); Assert.Contains("dist/index.mjs", result); } #endregion #region exports field tests [Fact] public async Task ExportsField_StringFormat_DetectsEntrypoint() { // Arrange var packageJson = new { name = "exports-str-pkg", version = "1.0.0", exports = "./dist/index.js" }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("dist/index.js", "// index"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("dist/index.js", result); } [Fact] public async Task ExportsField_ObjectWithImportRequire_DetectsBothEntrypoints() { // Arrange var packageJson = new { name = "exports-obj-pkg", version = "1.0.0", exports = new { import = "./dist/index.mjs", require = "./dist/index.cjs" } }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("dist/index.mjs", "// esm"); WriteFile("dist/index.cjs", "// cjs"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("dist/index.mjs", result); Assert.Contains("dist/index.cjs", result); } [Fact] public async Task ExportsField_MultipleSubpaths_DetectsAllEntrypoints() { // Arrange - Using raw JSON to match the exact structure var packageJsonContent = @"{ ""name"": ""exports-multi-pkg"", ""version"": ""1.0.0"", ""exports"": { ""."": ""./dist/index.js"", ""./utils"": ""./dist/utils.js"", ""./types"": ""./dist/types.d.ts"" } }"; WriteFile("package.json", packageJsonContent); WriteFile("dist/index.js", "// index"); WriteFile("dist/utils.js", "// utils"); WriteFile("dist/types.d.ts", "// types"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("dist/index.js", result); Assert.Contains("dist/utils.js", result); Assert.Contains("dist/types.d.ts", result); } [Fact] public async Task ExportsField_ConditionalExports_DetectsEntrypoints() { // Arrange var packageJsonContent = @"{ ""name"": ""conditional-exports-pkg"", ""version"": ""1.0.0"", ""exports"": { ""."": { ""import"": ""./dist/index.mjs"", ""require"": ""./dist/index.cjs"", ""default"": ""./dist/index.js"" } } }"; WriteFile("package.json", packageJsonContent); WriteFile("dist/index.mjs", "// esm"); WriteFile("dist/index.cjs", "// cjs"); WriteFile("dist/index.js", "// default"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("dist/index.mjs", result); Assert.Contains("dist/index.cjs", result); Assert.Contains("dist/index.js", result); } [Fact] public async Task ExportsField_NestedConditions_FlattensAndDetectsEntrypoints() { // Arrange var packageJsonContent = @"{ ""name"": ""nested-exports-pkg"", ""version"": ""1.0.0"", ""exports"": { ""."": { ""node"": { ""import"": ""./dist/node.mjs"", ""require"": ""./dist/node.cjs"" }, ""browser"": ""./dist/browser.js"" } } }"; WriteFile("package.json", packageJsonContent); WriteFile("dist/node.mjs", "// node esm"); WriteFile("dist/node.cjs", "// node cjs"); WriteFile("dist/browser.js", "// browser"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("dist/node.mjs", result); Assert.Contains("dist/node.cjs", result); Assert.Contains("dist/browser.js", result); } #endregion #region imports field tests [Fact] public async Task ImportsField_DetectsEntrypoints() { // Arrange var packageJsonContent = @"{ ""name"": ""imports-pkg"", ""version"": ""1.0.0"", ""imports"": { ""#internal"": ""./src/internal.js"" } }"; WriteFile("package.json", packageJsonContent); WriteFile("src/internal.js", "// internal"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("src/internal.js", result); } #endregion #region worker field tests [Fact] public async Task WorkerField_DetectsEntrypoint() { // Arrange var packageJson = new { name = "worker-pkg", version = "1.0.0", worker = "./dist/worker.js" }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("dist/worker.js", "// worker"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("dist/worker.js", result); Assert.Contains("worker", result); // condition set } #endregion #region electron detection tests [Fact] public async Task ElectronDependency_DetectsElectronEntrypoint() { // Arrange var packageJson = new { name = "electron-app", version = "1.0.0", main = "./src/main.js", dependencies = new { electron = "^25.0.0" } }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("src/main.js", "// electron main"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("electron", result); } [Fact] public async Task ElectronDevDependency_DetectsElectronEntrypoint() { // Arrange var packageJson = new { name = "electron-dev-app", version = "1.0.0", main = "./src/main.js", devDependencies = new { electron = "^25.0.0" } }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("src/main.js", "// electron main"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("electron", result); } #endregion #region shebang detection tests [Fact] public async Task ShebangScript_NodeShebang_DetectsEntrypoint() { // Arrange var packageJson = new { name = "shebang-pkg", version = "1.0.0" }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("cli.js", "#!/usr/bin/env node\nconsole.log('cli');"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("cli.js", result); Assert.Contains("shebang:node", result); } [Fact] public async Task ShebangScript_DirectNodePath_DetectsEntrypoint() { // Arrange var packageJson = new { name = "shebang-direct-pkg", version = "1.0.0" }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("cli.mjs", "#!/usr/bin/node\nconsole.log('cli');"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("cli.mjs", result); } [Fact] public async Task ShebangScript_NotNode_DoesNotDetect() { // Arrange var packageJson = new { name = "shebang-bash-pkg", version = "1.0.0" }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("script.sh", "#!/bin/bash\necho 'hello'"); WriteFile("some.js", "// not a shebang"); // Act var result = await RunAnalyzerAsync(); // Assert // Should not contain shebang:node for non-node scripts var json = JsonDocument.Parse(result); var hasNodeShebang = json.RootElement.EnumerateArray() .Any(p => p.ToString().Contains("shebang:node")); // The .sh file won't be scanned for shebangs (wrong extension) // The .js file doesn't have a shebang } [Fact] public async Task ShebangScript_TypeScriptExtension_DetectsEntrypoint() { // Arrange var packageJson = new { name = "shebang-ts-pkg", version = "1.0.0" }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("cli.ts", "#!/usr/bin/env node\nconsole.log('cli');"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("cli.ts", result); } [Fact] public async Task ShebangScript_WithLeadingWhitespace_DetectsEntrypoint() { // Arrange var packageJson = new { name = "shebang-ws-pkg", version = "1.0.0" }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("cli.js", " #!/usr/bin/env node\nconsole.log('cli');"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("cli.js", result); } #endregion #region path normalization tests [Fact] public async Task PathNormalization_LeadingDotSlash_IsNormalized() { // Arrange var packageJson = new { name = "path-norm-pkg", version = "1.0.0", main = "./dist/index.js" }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("dist/index.js", "// index"); // Act var result = await RunAnalyzerAsync(); // Assert // Path should be normalized (leading ./ stripped in entrypoint path) // The entrypoint evidence contains the normalized path var json = JsonDocument.Parse(result); var evidence = json.RootElement.EnumerateArray() .SelectMany(p => p.TryGetProperty("evidence", out var ev) ? ev.EnumerateArray() : Enumerable.Empty()) .Where(e => e.TryGetProperty("source", out var src) && src.GetString() == "package.json:entrypoint") .ToList(); // Should have entrypoint evidence with normalized path (starts with dist/, not ./dist/) Assert.True(evidence.Any(e => e.TryGetProperty("value", out var val) && val.GetString()!.StartsWith("dist/", StringComparison.Ordinal))); } [Fact] public async Task PathNormalization_MultipleLeadingDotSlash_IsNormalized() { // Arrange var packageJson = new { name = "multi-dot-pkg", version = "1.0.0", main = "././dist/index.js" }; WriteFile("package.json", JsonSerializer.Serialize(packageJson)); WriteFile("dist/index.js", "// index"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("dist/index.js", result); } [Fact] public async Task PathNormalization_BackslashesAreNormalized() { // Arrange - Windows-style paths var packageJsonContent = @"{ ""name"": ""backslash-pkg"", ""version"": ""1.0.0"", ""main"": ""dist\\index.js"" }"; WriteFile("package.json", packageJsonContent); WriteFile("dist/index.js", "// index"); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("dist/index.js", result); } #endregion #region edge cases [Fact] public async Task EmptyBinField_DoesNotCrash() { // Arrange var packageJsonContent = @"{ ""name"": ""empty-bin-pkg"", ""version"": ""1.0.0"", ""bin"": {} }"; WriteFile("package.json", packageJsonContent); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("empty-bin-pkg", result); } [Fact] public async Task EmptyExportsField_DoesNotCrash() { // Arrange var packageJsonContent = @"{ ""name"": ""empty-exports-pkg"", ""version"": ""1.0.0"", ""exports"": {} }"; WriteFile("package.json", packageJsonContent); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("empty-exports-pkg", result); } [Fact] public async Task NullBinValue_DoesNotCrash() { // Arrange var packageJsonContent = @"{ ""name"": ""null-bin-pkg"", ""version"": ""1.0.0"", ""bin"": null }"; WriteFile("package.json", packageJsonContent); // Act var result = await RunAnalyzerAsync(); // Assert Assert.Contains("null-bin-pkg", result); } [Fact] public async Task WhitespaceEntrypoint_DoesNotDetect() { // Arrange var packageJsonContent = @"{ ""name"": ""whitespace-main-pkg"", ""version"": ""1.0.0"", ""main"": "" "" }"; WriteFile("package.json", packageJsonContent); // Act var result = await RunAnalyzerAsync(); // Assert // Package should exist but whitespace main should not create entrypoint Assert.Contains("whitespace-main-pkg", result); } #endregion private async Task RunAnalyzerAsync() { var analyzers = new ILanguageAnalyzer[] { new NodeLanguageAnalyzer() }; return await LanguageAnalyzerTestHarness.RunToJsonAsync( _tempDir, analyzers, TestContext.Current.CancellationToken); } }