File: Scaffolding\ScaffoldingServiceTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json.Nodes;
using Aspire.Cli.Scaffolding;
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.Utils;
 
namespace Aspire.Cli.Tests.Scaffolding;
 
public class ScaffoldingServiceTests
{
    private static readonly LanguageInfo s_typeScriptLanguage = new(
        LanguageId: new LanguageId(KnownLanguageId.TypeScript),
        DisplayName: "TypeScript (Node.js)",
        PackageName: "@aspire/app-host",
        DetectionPatterns: ["apphost.mts", "apphost.ts"],
        CodeGenerator: "TypeScript",
        AppHostFileName: "apphost.mts");
 
    private static readonly LanguageInfo s_pythonLanguage = new(
        LanguageId: new LanguageId("python"),
        DisplayName: "Python",
        PackageName: "aspire-app-host",
        DetectionPatterns: ["apphost.py"],
        CodeGenerator: "python",
        AppHostFileName: "apphost.py");
 
    [Fact]
    public void GetScaffoldDirectory_UsesNestedPackage_ForBrownfieldTypeScript()
    {
        var rootDirectory = Directory.CreateTempSubdirectory();
 
        try
        {
            File.WriteAllText(Path.Combine(rootDirectory.FullName, "package.json"), "{}");
 
            var scaffoldDirectory = ScaffoldingService.GetScaffoldDirectory(rootDirectory, s_typeScriptLanguage);
 
            Assert.Equal(
                Path.Combine(rootDirectory.FullName, ScaffoldingService.BrownfieldTypeScriptAppHostDirectoryName),
                scaffoldDirectory.FullName);
        }
        finally
        {
            rootDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void GetScaffoldDirectory_UsesRoot_ForGreenfieldTypeScript()
    {
        var rootDirectory = Directory.CreateTempSubdirectory();
 
        try
        {
            var scaffoldDirectory = ScaffoldingService.GetScaffoldDirectory(rootDirectory, s_typeScriptLanguage);
 
            Assert.Equal(rootDirectory.FullName, scaffoldDirectory.FullName);
        }
        finally
        {
            rootDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void GetScaffoldDirectory_UsesRoot_ForNonTypeScript()
    {
        var rootDirectory = Directory.CreateTempSubdirectory();
 
        try
        {
            File.WriteAllText(Path.Combine(rootDirectory.FullName, "package.json"), "{}");
 
            var scaffoldDirectory = ScaffoldingService.GetScaffoldDirectory(rootDirectory, s_pythonLanguage);
 
            Assert.Equal(rootDirectory.FullName, scaffoldDirectory.FullName);
        }
        finally
        {
            rootDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void SerializePackageJson_PreservesTrailingNewLine_WhenOriginalHadOne()
    {
        var packageJson = JsonNode.Parse("""{ "scripts": { "aspire:start": "npm --prefix aspire-apphost run aspire:start" } }""")!.AsObject();
 
        var serialized = ScaffoldingService.SerializePackageJson(packageJson, "{\n}\n");
 
        Assert.EndsWith("\n", serialized);
    }
 
    [Fact]
    public void SerializePackageJson_PreservesTrailingNewLineStyle_WhenOriginalHadWindowsNewLine()
    {
        var packageJson = JsonNode.Parse("""{ "scripts": { "aspire:start": "npm --prefix aspire-apphost run aspire:start" } }""")!.AsObject();
 
        var serialized = ScaffoldingService.SerializePackageJson(packageJson, "{\r\n}\r\n");
 
        Assert.EndsWith("\r\n", serialized);
    }
 
    [Fact]
    public void SerializePackageJson_DoesNotAddTrailingNewLine_WhenOriginalDidNotHaveOne()
    {
        var packageJson = JsonNode.Parse("""{ "scripts": { "aspire:start": "npm --prefix aspire-apphost run aspire:start" } }""")!.AsObject();
 
        var serialized = ScaffoldingService.SerializePackageJson(packageJson, "{}");
 
        Assert.False(serialized.EndsWith(Environment.NewLine, StringComparison.Ordinal));
    }
 
    [Fact]
    public void AddRootTypeScriptAppHostDelegateScripts_AddsMissingScriptsWithSelectedToolchain()
    {
        var scripts = JsonNode.Parse("""{ "test": "vitest" }""")!.AsObject();
 
        var preservedScriptNames = ScaffoldingService.AddRootTypeScriptAppHostDelegateScripts(
            scripts,
            TypeScriptAppHostToolchain.Pnpm,
            "apps/web/aspire-apphost");
 
        Assert.Empty(preservedScriptNames);
        Assert.Equal("vitest", scripts["test"]?.GetValue<string>());
        Assert.Equal("pnpm --dir apps/web/aspire-apphost run aspire:start", scripts["aspire:start"]?.GetValue<string>());
        Assert.Equal("pnpm --dir apps/web/aspire-apphost run aspire:build", scripts["aspire:build"]?.GetValue<string>());
        Assert.Equal("pnpm --dir apps/web/aspire-apphost run aspire:dev", scripts["aspire:dev"]?.GetValue<string>());
    }
 
    [Fact]
    public void AddRootTypeScriptAppHostDelegateScripts_UsesAppHostToolchain()
    {
        var rootDirectory = Directory.CreateTempSubdirectory();
 
        try
        {
            var appHostDirectory = Directory.CreateDirectory(Path.Combine(rootDirectory.FullName, ScaffoldingService.BrownfieldTypeScriptAppHostDirectoryName));
            File.WriteAllText(Path.Combine(rootDirectory.FullName, "package.json"), "{ \"packageManager\": \"npm@10.0.0\" }");
            File.WriteAllText(Path.Combine(appHostDirectory.FullName, "package.json"), "{ \"packageManager\": \"pnpm@10.0.0\" }");
            var scripts = new JsonObject();
 
            var preservedScriptNames = ScaffoldingService.AddRootTypeScriptAppHostDelegateScripts(
                scripts,
                appHostDirectory,
                ScaffoldingService.BrownfieldTypeScriptAppHostDirectoryName,
                new TestEnvironment(),
                logger: null);
 
            Assert.Empty(preservedScriptNames);
            Assert.Equal("pnpm --dir aspire-apphost run aspire:start", scripts["aspire:start"]?.GetValue<string>());
            Assert.Equal("pnpm --dir aspire-apphost run aspire:build", scripts["aspire:build"]?.GetValue<string>());
            Assert.Equal("pnpm --dir aspire-apphost run aspire:dev", scripts["aspire:dev"]?.GetValue<string>());
        }
        finally
        {
            rootDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void AddRootTypeScriptAppHostDelegateScripts_PreservesExistingAspireScripts()
    {
        var scripts = JsonNode.Parse("""
            {
              "aspire:start": "custom-start",
              "aspire:build": "npm --prefix aspire-apphost run aspire:build"
            }
            """)!.AsObject();
 
        var preservedScriptNames = ScaffoldingService.AddRootTypeScriptAppHostDelegateScripts(
            scripts,
            TypeScriptAppHostToolchain.Npm,
            "aspire-apphost");
 
        Assert.Equal(["aspire:start"], preservedScriptNames);
        Assert.Equal("custom-start", scripts["aspire:start"]?.GetValue<string>());
        Assert.Equal("npm --prefix aspire-apphost run aspire:build", scripts["aspire:build"]?.GetValue<string>());
        Assert.Equal("npm --prefix aspire-apphost run aspire:dev", scripts["aspire:dev"]?.GetValue<string>());
    }
 
    [Fact]
    public void GetScaffoldedAppHostRelativePath_UsesActualScaffoldedFile_WhenDefaultFileNameDiffers()
    {
        var rootDirectory = Directory.CreateTempSubdirectory();
 
        try
        {
            var relativePath = ScaffoldingService.GetScaffoldedAppHostRelativePath(
                rootDirectory,
                rootDirectory,
                s_typeScriptLanguage,
                ["apphost.ts"]);
 
            Assert.Equal("apphost.ts", relativePath);
        }
        finally
        {
            rootDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void GetScaffoldedAppHostRelativePath_UsesNestedActualScaffoldedFile_ForBrownfieldTypeScript()
    {
        var rootDirectory = Directory.CreateTempSubdirectory();
 
        try
        {
            var scaffoldDirectory = Directory.CreateDirectory(Path.Combine(rootDirectory.FullName, ScaffoldingService.BrownfieldTypeScriptAppHostDirectoryName));
 
            var relativePath = ScaffoldingService.GetScaffoldedAppHostRelativePath(
                rootDirectory,
                scaffoldDirectory,
                s_typeScriptLanguage,
                ["apphost.ts"]);
 
            Assert.Equal("aspire-apphost/apphost.ts", relativePath);
        }
        finally
        {
            rootDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void GetConflictingScaffoldFiles_IgnoresMergeableFilesButReturnsOtherExistingFiles()
    {
        var rootDirectory = Directory.CreateTempSubdirectory();
 
        try
        {
            File.WriteAllText(Path.Combine(rootDirectory.FullName, ".gitignore"), "node_modules/\n");
            File.WriteAllText(Path.Combine(rootDirectory.FullName, "package.json"), "{}");
            File.WriteAllText(Path.Combine(rootDirectory.FullName, "apphost.mts"), string.Empty);
 
            var conflicts = ScaffoldingService.GetConflictingScaffoldFiles(
                rootDirectory.FullName,
                [".gitignore", "package.json", "apphost.mts"]);
 
            Assert.Equal(["apphost.mts"], conflicts);
        }
        finally
        {
            rootDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void MergeGitIgnoreContent_AppendsMissingEntriesWithoutOverwritingExistingContent()
    {
        var existingContent = "node_modules/\ncustom/\n";
        var scaffoldContent = "node_modules/\ndist/\n.aspire/\n";
 
        var mergedContent = ScaffoldingService.MergeGitIgnoreContent(existingContent, scaffoldContent);
        var lines = mergedContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
 
        Assert.Equal(
            ["node_modules/", "custom/", "dist/", ".aspire/"],
            lines);
    }
 
    [Fact]
    public void GetConflictingScaffoldFiles_TreatsVsCodeSettingsAsMergeable()
    {
        // Almost every existing repository opened in VS Code already has a .vscode/settings.json,
        // and the Java scaffold writes one. Reporting it as a conflict aborts init entirely.
        var rootDirectory = Directory.CreateTempSubdirectory();
 
        try
        {
            Directory.CreateDirectory(Path.Combine(rootDirectory.FullName, ".vscode"));
            File.WriteAllText(Path.Combine(rootDirectory.FullName, ".vscode", "settings.json"), "{}");
 
            var conflicts = ScaffoldingService.GetConflictingScaffoldFiles(
                rootDirectory.FullName,
                [".vscode/settings.json"]);
 
            Assert.Empty(conflicts);
        }
        finally
        {
            rootDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void MergeVsCodeSettingsContent_AddsMissingSettingsAndKeepsTheDeveloperValues()
    {
        var existingContent = """
            {
              "editor.formatOnSave": true,
              "java.compile.nullAnalysis.mode": "automatic"
            }
            """;
        var scaffoldContent = """
            {
              "java.project.sourcePaths": [".", ".aspire/modules"],
              "java.compile.nullAnalysis.mode": "disabled"
            }
            """;
 
        var merged = JsonNode.Parse(
            ScaffoldingService.MergeVsCodeSettingsContent(existingContent, scaffoldContent))!.AsObject();
 
        Assert.True(merged["editor.formatOnSave"]!.GetValue<bool>());
        // A setting the developer chose is theirs, so the scaffold does not overwrite it.
        Assert.Equal("automatic", merged["java.compile.nullAnalysis.mode"]!.GetValue<string>());
        Assert.Equal([".", ".aspire/modules"], merged["java.project.sourcePaths"]!.AsArray().Select(v => v!.GetValue<string>()));
    }
 
    [Fact]
    public void MergeVsCodeSettingsContent_UnionsSourcePathsWithoutDroppingExistingEntries()
    {
        var existingContent = """
            {
              "java.project.sourcePaths": ["src/main/java", "."]
            }
            """;
        var scaffoldContent = """
            {
              "java.project.sourcePaths": [".", ".aspire/modules"]
            }
            """;
 
        var merged = JsonNode.Parse(
            ScaffoldingService.MergeVsCodeSettingsContent(existingContent, scaffoldContent))!.AsObject();
 
        Assert.Equal(
            ["src/main/java", ".", ".aspire/modules"],
            merged["java.project.sourcePaths"]!.AsArray().Select(v => v!.GetValue<string>()));
    }
 
    [Fact]
    public void MergeVsCodeSettingsContent_ReadsSettingsThatUseCommentsAndTrailingCommas()
    {
        // VS Code settings are JSONC, and its own UI writes the "// Place your settings" header.
        var existingContent = """
            {
              // Place your settings in this file.
              "editor.tabSize": 4,
            }
            """;
        var scaffoldContent = """
            {
              "java.project.sourcePaths": ["."]
            }
            """;
 
        var merged = JsonNode.Parse(
            ScaffoldingService.MergeVsCodeSettingsContent(existingContent, scaffoldContent))!.AsObject();
 
        Assert.Equal(4, merged["editor.tabSize"]!.GetValue<int>());
        Assert.Equal(["."], merged["java.project.sourcePaths"]!.AsArray().Select(v => v!.GetValue<string>()));
    }
 
    [Fact]
    public void MergeVsCodeSettingsContent_PreservesSettingsThatCannotBeParsed()
    {
        // A settings.json broken mid-edit, or one using a JSONC construct the parser does not accept.
        // Overwriting it discards editor configuration the developer may have accumulated for years,
        // and `aspire init` gives no warning that it happened.
        var existingContent = """
            {
              "editor.formatOnSave": true,
              "java.project.sourcePaths": ["src/main/java"
            }
            """;
        var scaffoldContent = """
            {
              "java.project.sourcePaths": [".", ".aspire/modules"]
            }
            """;
 
        var merged = ScaffoldingService.MergeVsCodeSettingsContent(existingContent, scaffoldContent);
 
        Assert.Equal(existingContent, merged);
    }
 
    [Fact]
    public void MergeVsCodeSettingsContent_LeavesTheFileAloneWhenEverySettingIsAlreadyPresent()
    {
        // Nothing to add means nothing is rewritten, so comments and formatting survive re-running init.
        var existingContent = """
            {
              // keep me
              "java.project.sourcePaths": [".", ".aspire/modules"]
            }
            """;
        var scaffoldContent = """
            {
              "java.project.sourcePaths": [".", ".aspire/modules"]
            }
            """;
 
        Assert.Equal(existingContent, ScaffoldingService.MergeVsCodeSettingsContent(existingContent, scaffoldContent));
    }
 
    [Fact]
    public void MergeVsCodeSettingsContent_KeepsTheExistingFileWhenItIsNotUsableJson()
    {
        // Preferring the scaffold here would silently discard the developer's whole settings file.
        var merged = ScaffoldingService.MergeVsCodeSettingsContent("not json at all", """{"a": 1}""");
 
        Assert.Equal("not json at all", merged);
    }
 
    [Fact]
    public void MergeGitIgnoreContent_DoesNotAddDuplicateAspireEntryWhenEquivalentEntryAlreadyExists()
    {
        var existingContent = "/.aspire/\n";
        var scaffoldContent = ".aspire/\n";
 
        var mergedContent = ScaffoldingService.MergeGitIgnoreContent(existingContent, scaffoldContent);
 
        Assert.Equal(existingContent, mergedContent);
    }
}