File: Scaffolding\PackageJsonMergerTests.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;
using System.Text.Json.Nodes;
using Aspire.Cli.Scaffolding;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
 
namespace Aspire.Cli.Tests.Scaffolding;
 
public class PackageJsonMergerTests
{
    private static string MergeJson(string existing, string scaffold, string toolchainCommand = "npm") =>
        PackageJsonMerger.Merge(existing, scaffold, NullLogger.Instance, toolchainCommand);
 
    private static JsonObject ParseJson(string json) =>
        JsonNode.Parse(json)!.AsObject();
 
    private static string GetScript(string mergedJson, string scriptName) =>
        ParseJson(mergedJson)["scripts"]![scriptName]?.GetValue<string>()!;
 
    private static JsonObject GetScripts(string mergedJson) =>
        ParseJson(mergedJson)["scripts"]!.AsObject();
 
    private static string? GetDep(string mergedJson, string section, string packageName) =>
        ParseJson(mergedJson)[section]?[packageName]?.GetValue<string>();
 
    [Fact]
    public void ConflictingScripts_AddedWithAspirePrefix()
    {
        var existing = """
            {
              "name": "my-app",
              "scripts": {
                "dev": "vite",
                "build": "vite build"
              }
            }
            """;
 
        var scaffold = """
            {
              "scripts": {
                "dev": "aspire run",
                "build": "tsc -p tsconfig.apphost.json",
                "lint": "eslint apphost.ts"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
        var scripts = GetScripts(result);
 
        // Existing scripts preserved
        Assert.Equal("vite", scripts["dev"]?.GetValue<string>());
        Assert.Equal("vite build", scripts["build"]?.GetValue<string>());
 
        // Conflicting scaffold scripts get aspire: prefix
        Assert.Equal("aspire run", scripts["aspire:dev"]?.GetValue<string>());
        Assert.Equal("tsc -p tsconfig.apphost.json", scripts["aspire:build"]?.GetValue<string>());
 
        // Non-conflicting scaffold script added directly
        Assert.Equal("eslint apphost.ts", scripts["lint"]?.GetValue<string>());
    }
 
    [Fact]
    public void NonConflictingScripts_AddedDirectly()
    {
        var existing = """
            {
              "name": "my-app",
              "scripts": {
                "test": "jest"
              }
            }
            """;
 
        var scaffold = """
            {
              "scripts": {
                "dev": "aspire run",
                "build": "tsc -p tsconfig.apphost.json",
                "lint": "eslint apphost.ts"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
        var scripts = GetScripts(result);
 
        // Existing preserved
        Assert.Equal("jest", scripts["test"]?.GetValue<string>());
 
        // All scaffold scripts added directly (no conflicts)
        Assert.Equal("aspire run", scripts["dev"]?.GetValue<string>());
        Assert.Equal("tsc -p tsconfig.apphost.json", scripts["build"]?.GetValue<string>());
        Assert.Equal("eslint apphost.ts", scripts["lint"]?.GetValue<string>());
    }
 
    [Fact]
    public void PrefixedScripts_PreserveExistingValues()
    {
        var existing = """
            {
              "name": "my-app",
              "scripts": {
                "dev": "vite",
                "build": "vite build",
                "aspire:start": "custom start"
              }
            }
            """;
 
        var scaffold = """
            {
              "scripts": {
                "aspire:start": "aspire run",
                "aspire:build": "tsc -p tsconfig.apphost.json",
                "aspire:dev": "tsc --watch -p tsconfig.apphost.json",
                "aspire:lint": "eslint apphost.ts"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
        var scripts = GetScripts(result);
 
        // Existing preserved
        Assert.Equal("vite", scripts["dev"]?.GetValue<string>());
        Assert.Equal("vite build", scripts["build"]?.GetValue<string>());
 
        // Existing aspire: scripts are preserved; missing ones are added
        Assert.Equal("custom start", scripts["aspire:start"]?.GetValue<string>());
        Assert.Equal("tsc -p tsconfig.apphost.json", scripts["aspire:build"]?.GetValue<string>());
        Assert.Equal("tsc --watch -p tsconfig.apphost.json", scripts["aspire:dev"]?.GetValue<string>());
        Assert.Equal("eslint apphost.ts", scripts["aspire:lint"]?.GetValue<string>());
    }
 
    [Fact]
    public void ConvenienceAliases_AddedForFreeNames()
    {
        var existing = """
            {
              "name": "my-app",
              "scripts": {
                "dev": "vite",
                "build": "vite build"
              }
            }
            """;
 
        var scaffold = """
            {
              "scripts": {
                "aspire:start": "aspire run",
                "aspire:build": "tsc -p tsconfig.apphost.json",
                "aspire:dev": "tsc --watch -p tsconfig.apphost.json",
                "aspire:lint": "eslint apphost.ts"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
        var scripts = GetScripts(result);
 
        // "start" and "lint" are not taken — convenience aliases added
        Assert.Equal("npm run aspire:start", scripts["start"]?.GetValue<string>());
        Assert.Equal("npm run aspire:lint", scripts["lint"]?.GetValue<string>());
 
        // "dev" and "build" are taken — no alias
        Assert.Equal("vite", scripts["dev"]?.GetValue<string>());
        Assert.Equal("vite build", scripts["build"]?.GetValue<string>());
    }
 
    [Fact]
    public void ConvenienceAliases_UseConfiguredToolchainCommand()
    {
        var existing = """
            {
              "name": "my-app",
              "packageManager": "yarn@4.9.0",
              "scripts": {
                "dev": "vite"
              }
            }
            """;
 
        var scaffold = """
            {
              "scripts": {
                "aspire:start": "aspire run"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold, toolchainCommand: "yarn");
        var scripts = GetScripts(result);
 
        Assert.Equal("yarn run aspire:start", scripts["start"]?.GetValue<string>());
    }
 
    [Fact]
    public void NoAliasWhenNameTaken()
    {
        var existing = """
            {
              "name": "my-app",
              "scripts": {
                "start": "node server.js",
                "lint": "prettier --check .",
                "dev": "vite",
                "build": "vite build"
              }
            }
            """;
 
        var scaffold = """
            {
              "scripts": {
                "aspire:start": "aspire run",
                "aspire:lint": "eslint apphost.ts",
                "aspire:build": "tsc -p tsconfig.apphost.json"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
        var scripts = GetScripts(result);
 
        // All existing scripts preserved
        Assert.Equal("node server.js", scripts["start"]?.GetValue<string>());
        Assert.Equal("prettier --check .", scripts["lint"]?.GetValue<string>());
        Assert.Equal("vite", scripts["dev"]?.GetValue<string>());
        Assert.Equal("vite build", scripts["build"]?.GetValue<string>());
 
        // Aspire scripts added
        Assert.Equal("aspire run", scripts["aspire:start"]?.GetValue<string>());
        Assert.Equal("eslint apphost.ts", scripts["aspire:lint"]?.GetValue<string>());
        Assert.Equal("tsc -p tsconfig.apphost.json", scripts["aspire:build"]?.GetValue<string>());
 
        // No convenience aliases — all unprefixed names are taken
        // Verify the existing values weren't overwritten with aliases
        Assert.Equal("node server.js", scripts["start"]?.GetValue<string>());
        Assert.Equal("prettier --check .", scripts["lint"]?.GetValue<string>());
    }
 
    [Fact]
    public void MixedConflicts_SomeScriptsPrefixedSomeNot()
    {
        var existing = """
            {
              "name": "my-app",
              "scripts": {
                "dev": "vite",
                "test": "jest"
              }
            }
            """;
 
        var scaffold = """
            {
              "scripts": {
                "dev": "aspire run",
                "build": "tsc -p tsconfig.apphost.json",
                "aspire:lint": "eslint apphost.ts"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
        var scripts = GetScripts(result);
 
        // Existing preserved
        Assert.Equal("vite", scripts["dev"]?.GetValue<string>());
        Assert.Equal("jest", scripts["test"]?.GetValue<string>());
 
        // "dev" conflicted → prefixed
        Assert.Equal("aspire run", scripts["aspire:dev"]?.GetValue<string>());
 
        // "build" didn't conflict → added directly
        Assert.Equal("tsc -p tsconfig.apphost.json", scripts["build"]?.GetValue<string>());
 
        // "aspire:lint" always added + alias since "lint" is free
        Assert.Equal("eslint apphost.ts", scripts["aspire:lint"]?.GetValue<string>());
        Assert.Equal("npm run aspire:lint", scripts["lint"]?.GetValue<string>());
    }
 
    [Fact]
    public void Dependencies_SemverAwareMerge()
    {
        var existing = """
            {
              "name": "my-app",
              "dependencies": {
                "express": "^4.18.0"
              },
              "devDependencies": {
                "typescript": "^5.0.0",
                "vite": "^5.0.0"
              }
            }
            """;
 
        var scaffold = """
            {
              "dependencies": {
                "vscode-jsonrpc": "^8.2.0",
                "express": "^5.0.0"
              },
              "devDependencies": {
                "typescript": "^5.9.3",
                "@types/node": "^22.0.0",
                "tsx": "^4.21.0"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        // Scaffold is newer — upgraded
        Assert.Equal("^5.0.0", GetDep(result, "dependencies", "express"));
        Assert.Equal("^5.9.3", GetDep(result, "devDependencies", "typescript"));
 
        // Not in scaffold — preserved
        Assert.Equal("^5.0.0", GetDep(result, "devDependencies", "vite"));
 
        // New deps added
        Assert.Equal("^8.2.0", GetDep(result, "dependencies", "vscode-jsonrpc"));
        Assert.Equal("^22.0.0", GetDep(result, "devDependencies", "@types/node"));
        Assert.Equal("^4.21.0", GetDep(result, "devDependencies", "tsx"));
    }
 
    [Fact]
    public void PreservesNonScriptProperties()
    {
        var existing = """
            {
              "name": "my-existing-app",
              "version": "3.0.0",
              "description": "My cool app",
              "private": true,
              "type": "module",
              "engines": {
                "node": ">=18"
              }
            }
            """;
 
        var scaffold = """
            {
              "name": "aspire-apphost",
              "version": "1.0.0",
              "type": "commonjs",
              "engines": {
                "node": "^20.19.0 || ^22.13.0 || >=24"
              },
              "scripts": {
                "aspire:build": "tsc -p tsconfig.apphost.json"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
        var json = ParseJson(result);
 
        // Existing scalars preserved
        Assert.Equal("my-existing-app", json["name"]?.GetValue<string>());
        Assert.Equal("3.0.0", json["version"]?.GetValue<string>());
        Assert.Equal("My cool app", json["description"]?.GetValue<string>());
        Assert.True(json["private"]?.GetValue<bool>());
        Assert.Equal("module", json["type"]?.GetValue<string>());
 
        // engines.node overwritten by scaffold (Aspire requires specific Node versions)
        Assert.Equal("^20.19.0 || ^22.13.0 || >=24", json["engines"]?["node"]?.GetValue<string>());
 
        // Script from scaffold is added
        Assert.Equal("tsc -p tsconfig.apphost.json", GetScript(result, "aspire:build"));
    }
 
    [Fact]
    public void EmptyExistingContent_ReturnsScaffold()
    {
        var scaffold = """
            {
              "name": "aspire-apphost",
              "scripts": { "dev": "aspire run" }
            }
            """;
 
        var result = MergeJson("", scaffold);
        Assert.Equal(scaffold, result);
 
        result = MergeJson("   ", scaffold);
        Assert.Equal(scaffold, result);
    }
 
    [Fact]
    public void MalformedExistingJson_ReturnsScaffold()
    {
        var scaffold = """
            {
              "name": "aspire-apphost",
              "scripts": { "dev": "aspire run" }
            }
            """;
 
        var result = MergeJson("not valid json {{{", scaffold);
        Assert.Equal(scaffold, result);
    }
 
    [Fact]
    public void ExistingJsonWithCommentsAndTrailingCommas_MergesSuccessfully()
    {
        // Real-world package.json files may contain comments and trailing commas
        // even though they're not valid per the JSON spec. We should tolerate them.
        var existing = """
            {
              // This is a comment
              "name": "my-app",
              "version": "1.0.0",
              "scripts": {
                "dev": "vite",
                "build": "vite build", // trailing comma
              },
              "dependencies": {
                "express": "^4.18.0",
              }
            }
            """;
 
        var scaffold = """
            {
              "scripts": { "aspire:start": "aspire run" },
              "dependencies": { "vscode-jsonrpc": "^8.2.0" }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
        var json = ParseJson(result);
 
        // Existing properties preserved (comments and trailing commas are stripped in output)
        Assert.Equal("my-app", json["name"]?.GetValue<string>());
        Assert.Equal("vite", GetScript(result, "dev"));
        Assert.Equal("^4.18.0", GetDep(result, "dependencies", "express"));
 
        // Scaffold content merged in
        Assert.Equal("aspire run", GetScript(result, "aspire:start"));
        Assert.Equal("^8.2.0", GetDep(result, "dependencies", "vscode-jsonrpc"));
    }
 
    [Fact]
    public void Idempotent_MergingTwiceProducesSameResult()
    {
        var existing = """
            {
              "name": "my-app",
              "scripts": {
                "dev": "vite",
                "build": "vite build"
              },
              "dependencies": {
                "express": "^4.18.0"
              }
            }
            """;
 
        var scaffold = """
            {
              "scripts": {
                "aspire:start": "aspire run",
                "aspire:build": "tsc -p tsconfig.apphost.json",
                "aspire:lint": "eslint apphost.ts"
              },
              "dependencies": {
                "vscode-jsonrpc": "^8.2.0"
              }
            }
            """;
 
        var firstMerge = MergeJson(existing, scaffold);
        var secondMerge = MergeJson(firstMerge, scaffold);
 
        // Parsing both to compare structurally (avoid whitespace differences)
        var first = ParseJson(firstMerge);
        var second = ParseJson(secondMerge);
 
        Assert.Equal(
            first.ToJsonString(new JsonSerializerOptions { WriteIndented = true }),
            second.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
    }
 
    [Fact]
    public void NoExistingScripts_ScaffoldScriptsAddedDirectly()
    {
        var existing = """
            {
              "name": "my-app",
              "version": "1.0.0"
            }
            """;
 
        var scaffold = """
            {
              "scripts": {
                "dev": "aspire run",
                "build": "tsc -p tsconfig.apphost.json",
                "lint": "eslint apphost.ts"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
        var scripts = GetScripts(result);
 
        // All scripts added directly (no existing scripts to conflict)
        Assert.Equal("aspire run", scripts["dev"]?.GetValue<string>());
        Assert.Equal("tsc -p tsconfig.apphost.json", scripts["build"]?.GetValue<string>());
        Assert.Equal("eslint apphost.ts", scripts["lint"]?.GetValue<string>());
    }
 
    [Fact]
    public void StaleServer_AllScriptsPreservedUnderAspirePrefix()
    {
        // Simulates the exact scenario: stale server sends non-prefixed scripts,
        // brownfield project already has dev/build/lint
        var existing = """
            {
              "name": "vite-project",
              "version": "1.0.0",
              "type": "module",
              "scripts": {
                "dev": "vite",
                "build": "vite build",
                "lint": "eslint . --ext .ts,.tsx",
                "preview": "vite preview"
              },
              "dependencies": {
                "react": "^18.2.0"
              },
              "devDependencies": {
                "typescript": "^5.2.0",
                "vite": "^5.0.0"
              }
            }
            """;
 
        var staleScaffold = """
            {
              "name": "aspire-apphost",
              "version": "1.0.0",
              "type": "module",
              "scripts": {
                "lint": "eslint apphost.ts",
                "dev": "aspire run",
                "build": "tsc -p tsconfig.apphost.json",
                "watch": "tsc --watch -p tsconfig.apphost.json"
              },
              "dependencies": {
                "vscode-jsonrpc": "^8.2.0"
              },
              "devDependencies": {
                "@types/node": "^22.0.0",
                "tsx": "^4.21.0",
                "typescript": "^5.9.3"
              }
            }
            """;
 
        var result = MergeJson(existing, staleScaffold);
        var json = ParseJson(result);
        var scripts = json["scripts"]!.AsObject();
 
        // Existing project identity preserved
        Assert.Equal("vite-project", json["name"]?.GetValue<string>());
        Assert.Equal("1.0.0", json["version"]?.GetValue<string>());
 
        // Existing scripts preserved
        Assert.Equal("vite", scripts["dev"]?.GetValue<string>());
        Assert.Equal("vite build", scripts["build"]?.GetValue<string>());
        Assert.Equal("eslint . --ext .ts,.tsx", scripts["lint"]?.GetValue<string>());
        Assert.Equal("vite preview", scripts["preview"]?.GetValue<string>());
 
        // Conflicting scaffold scripts added under aspire: prefix
        Assert.Equal("aspire run", scripts["aspire:dev"]?.GetValue<string>());
        Assert.Equal("tsc -p tsconfig.apphost.json", scripts["aspire:build"]?.GetValue<string>());
        Assert.Equal("eslint apphost.ts", scripts["aspire:lint"]?.GetValue<string>());
 
        // Non-conflicting scaffold script added directly
        Assert.Equal("tsc --watch -p tsconfig.apphost.json", scripts["watch"]?.GetValue<string>());
 
        // Existing deps preserved, new deps added, older deps upgraded
        Assert.Equal("^18.2.0", GetDep(result, "dependencies", "react"));
        Assert.Equal("^8.2.0", GetDep(result, "dependencies", "vscode-jsonrpc"));
        Assert.Equal("^5.9.3", GetDep(result, "devDependencies", "typescript")); // upgraded from ^5.2.0
        Assert.Equal("^5.0.0", GetDep(result, "devDependencies", "vite"));
        Assert.Equal("^22.0.0", GetDep(result, "devDependencies", "@types/node"));
        Assert.Equal("^4.21.0", GetDep(result, "devDependencies", "tsx"));
    }
 
    [Fact]
    public void UpdatedServer_AllScriptsAndAliasesPresent()
    {
        // Simulates the updated server which already sends aspire: prefixed scripts
        var existing = """
            {
              "name": "vite-project",
              "scripts": {
                "dev": "vite",
                "build": "vite build"
              }
            }
            """;
 
        var updatedScaffold = """
            {
              "scripts": {
                "aspire:start": "aspire run",
                "aspire:build": "tsc -p tsconfig.apphost.json",
                "aspire:dev": "tsc --watch -p tsconfig.apphost.json",
                "aspire:lint": "eslint apphost.ts"
              },
              "dependencies": {
                "vscode-jsonrpc": "^8.2.0"
              }
            }
            """;
 
        var result = MergeJson(existing, updatedScaffold);
        var scripts = GetScripts(result);
 
        // Existing preserved
        Assert.Equal("vite", scripts["dev"]?.GetValue<string>());
        Assert.Equal("vite build", scripts["build"]?.GetValue<string>());
 
        // All aspire: scripts present
        Assert.Equal("aspire run", scripts["aspire:start"]?.GetValue<string>());
        Assert.Equal("tsc -p tsconfig.apphost.json", scripts["aspire:build"]?.GetValue<string>());
        Assert.Equal("tsc --watch -p tsconfig.apphost.json", scripts["aspire:dev"]?.GetValue<string>());
        Assert.Equal("eslint apphost.ts", scripts["aspire:lint"]?.GetValue<string>());
 
        // Convenience aliases for free names (start, lint not taken)
        Assert.Equal("npm run aspire:start", scripts["start"]?.GetValue<string>());
        Assert.Equal("npm run aspire:lint", scripts["lint"]?.GetValue<string>());
 
        // No aliases for taken names (dev, build already exist)
        Assert.Equal("vite", scripts["dev"]?.GetValue<string>());
        Assert.Equal("vite build", scripts["build"]?.GetValue<string>());
    }
 
    [Fact]
    public void ScriptCommands_PreservedWithFullFidelity()
    {
        // npm scripts commonly use &&, quotes, pipes, and other shell characters.
        // The merger must write them back exactly as they were — no unicode escaping.
        var existing = """
            {
              "name": "my-app",
              "scripts": {
                "build": "tsc && vite build",
                "dev": "concurrently \"tsc -w\" \"vite\"",
                "test": "vitest run && echo 'done'",
                "lint": "eslint . --ext .ts,.tsx && prettier --check .",
                "clean": "rm -rf dist && rm -rf node_modules/.cache",
                "start": "node server.js | tee output.log"
              }
            }
            """;
 
        var scaffold = """
            {
              "scripts": {
                "aspire:build": "tsc -p tsconfig.apphost.json",
                "aspire:start": "aspire run"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        // Verify the raw JSON string contains literal &&, ', and | — not unicode escapes
        Assert.Contains("tsc && vite build", result);
        Assert.Contains("vitest run && echo 'done'", result);
        Assert.Contains("eslint . --ext .ts,.tsx && prettier --check .", result);
        Assert.Contains("rm -rf dist && rm -rf node_modules/.cache", result);
        Assert.Contains("node server.js | tee output.log", result);
 
        // Quotes inside JSON string values are written as \" (valid JSON) — verify via raw string
        Assert.Contains("concurrently \\\"tsc -w\\\" \\\"vite\\\"", result);
 
        // Must not contain unicode-escaped ampersands or single quotes
        Assert.DoesNotContain("\\u0026", result);
        Assert.DoesNotContain("\\u0027", result);
 
        // Also verify the parsed values round-trip correctly
        var scripts = GetScripts(result);
        Assert.Equal("tsc && vite build", scripts["build"]?.GetValue<string>());
        Assert.Equal("concurrently \"tsc -w\" \"vite\"", scripts["dev"]?.GetValue<string>());
        Assert.Equal("vitest run && echo 'done'", scripts["test"]?.GetValue<string>());
        Assert.Equal("eslint . --ext .ts,.tsx && prettier --check .", scripts["lint"]?.GetValue<string>());
        Assert.Equal("rm -rf dist && rm -rf node_modules/.cache", scripts["clean"]?.GetValue<string>());
        Assert.Equal("node server.js | tee output.log", scripts["start"]?.GetValue<string>());
    }
 
    [Fact]
    public void ScaffoldScriptCommands_AlsoPreservedWithFullFidelity()
    {
        // Even scaffold-generated commands with special chars must be written faithfully
        var existing = """
            {
              "name": "my-app",
              "scripts": {
                "dev": "next dev"
              }
            }
            """;
 
        var scaffold = """
            {
              "scripts": {
                "aspire:lint": "eslint apphost.ts && echo 'lint complete'",
                "aspire:build": "tsc -p tsconfig.apphost.json && echo 'build done'"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        Assert.Contains("eslint apphost.ts && echo 'lint complete'", result);
        Assert.Contains("tsc -p tsconfig.apphost.json && echo 'build done'", result);
        Assert.DoesNotContain("\\u0026", result);
        Assert.DoesNotContain("\\u0027", result);
    }
 
    [Fact]
    public void Dependencies_ScaffoldNewerVersion_Upgrades()
    {
        var existing = """
            {
              "name": "my-app",
              "devDependencies": {
                "typescript": "^4.0.0"
              }
            }
            """;
 
        var scaffold = """
            {
              "devDependencies": {
                "typescript": "^5.9.3"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
        Assert.Equal("^5.9.3", GetDep(result, "devDependencies", "typescript"));
    }
 
    [Fact]
    public void Dependencies_ExistingNewerVersion_Preserved()
    {
        var existing = """
            {
              "name": "my-app",
              "devDependencies": {
                "typescript": "^6.0.0"
              }
            }
            """;
 
        var scaffold = """
            {
              "devDependencies": {
                "typescript": "^5.9.3"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
        Assert.Equal("^6.0.0", GetDep(result, "devDependencies", "typescript"));
    }
 
    [Fact]
    public void Dependencies_TildeRange_Compared()
    {
        var existing = """
            {
              "name": "my-app",
              "devDependencies": {
                "typescript": "~5.0.0"
              }
            }
            """;
 
        var scaffold = """
            {
              "devDependencies": {
                "typescript": "^5.9.3"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        // Scaffold is newer (5.9.3 > 5.0.0), upgrades — entire value replaced including range operator
        Assert.Equal("^5.9.3", GetDep(result, "devDependencies", "typescript"));
    }
 
    [Fact]
    public void Dependencies_UnionRange_Preserved()
    {
        var existing = """
            {
              "name": "my-app",
              "dependencies": {
                "some-pkg": "^1.0.0 || ^2.0.0"
              }
            }
            """;
 
        var scaffold = """
            {
              "dependencies": {
                "some-pkg": "^3.0.0"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        // Union ranges are unparseable — existing preserved
        Assert.Equal("^1.0.0 || ^2.0.0", GetDep(result, "dependencies", "some-pkg"));
    }
 
    [Fact]
    public void Dependencies_WorkspaceRef_Preserved()
    {
        var existing = """
            {
              "name": "my-app",
              "dependencies": {
                "shared-lib": "workspace:*"
              }
            }
            """;
 
        var scaffold = """
            {
              "dependencies": {
                "shared-lib": "^1.0.0"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        // Workspace refs are not parseable as semver — existing preserved
        Assert.Equal("workspace:*", GetDep(result, "dependencies", "shared-lib"));
    }
 
    [Fact]
    public void Dependencies_NewDependency_Added()
    {
        var existing = """
            {
              "name": "my-app",
              "dependencies": {
                "express": "^4.18.0"
              }
            }
            """;
 
        var scaffold = """
            {
              "dependencies": {
                "vscode-jsonrpc": "^8.2.0"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        Assert.Equal("^4.18.0", GetDep(result, "dependencies", "express"));
        Assert.Equal("^8.2.0", GetDep(result, "dependencies", "vscode-jsonrpc"));
    }
 
    [Fact]
    public void NonStringScriptValue_SkippedGracefully()
    {
        var existing = """
            {
              "name": "my-app",
              "scripts": {
                "dev": "vite"
              }
            }
            """;
 
        // Scaffold has an array value for a script (unusual but should not crash)
        var scaffold = """
            {
              "scripts": {
                "aspire:start": "aspire run",
                "bad-script": [1, 2, 3]
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        // Valid scripts still merged, invalid ones skipped
        Assert.Equal("vite", GetScript(result, "dev"));
        Assert.Equal("aspire run", GetScript(result, "aspire:start"));
        Assert.Null(ParseJson(result)["scripts"]!["bad-script"]);
    }
 
    [Fact]
    public void NonStringDependencyValue_SkippedGracefully()
    {
        var existing = """
            {
              "name": "my-app",
              "dependencies": {
                "express": "^4.18.0"
              }
            }
            """;
 
        var scaffold = """
            {
              "dependencies": {
                "vscode-jsonrpc": "^8.2.0",
                "bad-dep": ["1.0.0"]
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        // Valid deps merged, non-string ones skipped
        Assert.Equal("^4.18.0", GetDep(result, "dependencies", "express"));
        Assert.Equal("^8.2.0", GetDep(result, "dependencies", "vscode-jsonrpc"));
        Assert.Null(GetDep(result, "dependencies", "bad-dep"));
    }
 
    [Fact]
    public void NonStringExistingDependency_PreservedNotCrashed()
    {
        var existing = """
            {
              "name": "my-app",
              "dependencies": {
                "weird-pkg": { "version": "1.0.0", "optional": true }
              }
            }
            """;
 
        var scaffold = """
            {
              "dependencies": {
                "weird-pkg": "^2.0.0",
                "vscode-jsonrpc": "^8.2.0"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        // Non-string existing dep preserved (upgrade skipped due to type mismatch)
        var weirdPkg = ParseJson(result)["dependencies"]!["weird-pkg"];
        Assert.NotNull(weirdPkg);
        Assert.True(weirdPkg is JsonObject);
 
        // New deps still added
        Assert.Equal("^8.2.0", GetDep(result, "dependencies", "vscode-jsonrpc"));
    }
 
    [Fact]
    public void DependenciesSectionIsArray_HandledGracefully()
    {
        var existing = """
            {
              "name": "my-app",
              "dependencies": ["express", "react"]
            }
            """;
 
        var scaffold = """
            {
              "dependencies": {
                "vscode-jsonrpc": "^8.2.0"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        // EnsureObject replaces the array with a proper object containing scaffold deps
        Assert.Equal("^8.2.0", GetDep(result, "dependencies", "vscode-jsonrpc"));
    }
 
    [Fact]
    public void JsonRootIsArray_ReturnsScaffold()
    {
        var existing = """["not", "an", "object"]""";
 
        var scaffold = """
            {
              "name": "scaffold",
              "scripts": { "dev": "aspire run" }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        // Can't merge into an array — returns scaffold as-is
        Assert.Equal("scaffold", ParseJson(result)["name"]?.GetValue<string>());
    }
 
    [Fact]
    public void WildcardVersion_Preserved()
    {
        var existing = """
            {
              "dependencies": {
                "some-pkg": "*"
              }
            }
            """;
 
        var scaffold = """
            {
              "dependencies": {
                "some-pkg": "^2.0.0"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        // "*" is unparseable — existing preserved
        Assert.Equal("*", GetDep(result, "dependencies", "some-pkg"));
    }
 
    [Fact]
    public void LatestTag_Preserved()
    {
        var existing = """
            {
              "dependencies": {
                "some-pkg": "latest"
              }
            }
            """;
 
        var scaffold = """
            {
              "dependencies": {
                "some-pkg": "^2.0.0"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        // "latest" is unparseable — existing preserved
        Assert.Equal("latest", GetDep(result, "dependencies", "some-pkg"));
    }
 
    [Fact]
    public void PreReleaseVersion_ComparedCorrectly()
    {
        var existing = """
            {
              "devDependencies": {
                "typescript": "^5.9.3-beta.1"
              }
            }
            """;
 
        var scaffold = """
            {
              "devDependencies": {
                "typescript": "^5.9.3"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        // 5.9.3 release is newer than 5.9.3-beta.1 pre-release
        Assert.Equal("^5.9.3", GetDep(result, "devDependencies", "typescript"));
    }
 
    [Fact]
    public void DevDependencyAlreadyInDependencies_IsNotDuplicated()
    {
        var existing = """
            {
              "dependencies": {
                "vscode-jsonrpc": "^8.1.0"
              }
            }
            """;
 
        var scaffold = """
            {
              "devDependencies": {
                "vscode-jsonrpc": "^8.2.0",
                "typescript": "^5.9.3"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        Assert.Equal("^8.2.0", GetDep(result, "dependencies", "vscode-jsonrpc"));
        Assert.Null(GetDep(result, "devDependencies", "vscode-jsonrpc"));
        Assert.Equal("^5.9.3", GetDep(result, "devDependencies", "typescript"));
    }
 
    [Fact]
    public void Engines_NodeConstraint_OverwrittenByScaffold()
    {
        var existing = """
            {
              "name": "my-app",
              "engines": {
                "node": ">=16"
              }
            }
            """;
 
        var scaffold = """
            {
              "engines": {
                "node": "^20.19.0 || ^22.13.0 || >=24"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        // engines.node is always overwritten — aspire init enforces Node version for ESLint 10
        var engines = ParseJson(result)["engines"]!.AsObject();
        Assert.Equal("^20.19.0 || ^22.13.0 || >=24", engines["node"]?.GetValue<string>());
    }
 
    [Fact]
    public void Engines_OtherKeys_Preserved()
    {
        var existing = """
            {
              "name": "my-app",
              "engines": {
                "node": ">=16",
                "npm": ">=8"
              }
            }
            """;
 
        var scaffold = """
            {
              "engines": {
                "node": "^20.19.0 || ^22.13.0 || >=24"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        var engines = ParseJson(result)["engines"]!.AsObject();
        // node overwritten by scaffold
        Assert.Equal("^20.19.0 || ^22.13.0 || >=24", engines["node"]?.GetValue<string>());
        // npm preserved from existing
        Assert.Equal(">=8", engines["npm"]?.GetValue<string>());
    }
 
    [Fact]
    public void Engines_AddedWhenMissing()
    {
        var existing = """
            {
              "name": "my-app"
            }
            """;
 
        var scaffold = """
            {
              "engines": {
                "node": "^20.19.0 || ^22.13.0 || >=24"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
 
        var engines = ParseJson(result)["engines"]!.AsObject();
        Assert.Equal("^20.19.0 || ^22.13.0 || >=24", engines["node"]?.GetValue<string>());
    }
 
    [Fact]
    public void ScaffoldWithArrayProperty_PreservesExistingArray()
    {
        var existing = """
            {
              "name": "my-app",
              "keywords": ["web", "api"]
            }
            """;
 
        var scaffold = """
            {
              "keywords": ["web", "api"],
              "files": ["dist/**", "README.md"]
            }
            """;
 
        // When both have an array, existing wins (preserved). Scaffold-only arrays are added.
        var result = MergeJson(existing, scaffold);
        var doc = JsonNode.Parse(result)!.AsObject();
 
        var keywords = doc["keywords"]!.AsArray();
        Assert.Equal(2, keywords.Count);
        Assert.Equal("web", keywords[0]!.GetValue<string>());
        Assert.Equal("api", keywords[1]!.GetValue<string>());
 
        var files = doc["files"]!.AsArray();
        Assert.Equal(2, files.Count);
        Assert.Equal("dist/**", files[0]!.GetValue<string>());
    }
 
    [Fact]
    public void BrownfieldNpmInit_MergesSuccessfully()
    {
        // Reproduces the real-world scenario where npm init creates a package.json
        // and the scaffold produces only Aspire-desired entries (no echo of existing content).
        var existing = """
            {
              "name": "my-project",
              "version": "1.0.0",
              "main": "index.js",
              "scripts": {
                "test": "echo \"Error: no test specified\" && exit 1"
              },
              "keywords": [],
              "author": "",
              "license": "ISC",
              "description": ""
            }
            """;
 
        var scaffold = """
            {
              "scripts": {
                "aspire:start": "aspire run",
                "aspire:build": "tsc -p tsconfig.apphost.json",
                "aspire:lint": "eslint apphost.ts"
              },
              "dependencies": {
                "vscode-jsonrpc": "^8.2.0"
              },
              "devDependencies": {
                "typescript": "^5.9.3",
                "tsx": "^4.21.0"
              },
              "engines": {
                "node": "^20.19.0 || ^22.13.0 || >=24"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
        var doc = JsonNode.Parse(result)!.AsObject();
 
        // Original fields preserved
        Assert.Equal("my-project", doc["name"]!.GetValue<string>());
        Assert.Equal("ISC", doc["license"]!.GetValue<string>());
 
        // Array preserved (empty keywords from npm init)
        Assert.NotNull(doc["keywords"]);
        Assert.IsAssignableFrom<JsonArray>(doc["keywords"]);
 
        // Aspire scripts added, existing test script preserved
        var scripts = doc["scripts"]!.AsObject();
        Assert.Contains("test", scripts.Select(p => p.Key));
        Assert.Contains("aspire:start", scripts.Select(p => p.Key));
        Assert.Contains("aspire:build", scripts.Select(p => p.Key));
 
        // Dependencies merged
        Assert.NotNull(doc["dependencies"]?["vscode-jsonrpc"]);
        Assert.NotNull(doc["devDependencies"]?["typescript"]);
 
        // Engines set
        Assert.Contains(">=24", doc["engines"]?["node"]?.GetValue<string>());
    }
 
    [Fact]
    public void EnsureObject_LogsWarning_WhenReplacingArrayWithObject()
    {
        var existing = """
            {
              "name": "my-app",
              "dependencies": ["express", "react"]
            }
            """;
 
        var scaffold = """
            {
              "dependencies": {
                "vscode-jsonrpc": "^8.2.0"
              }
            }
            """;
 
        var sink = new TestSink();
        var logger = new TestLogger("test", sink, enabled: true);
 
        PackageJsonMerger.Merge(existing, scaffold, logger);
 
        var warning = Assert.Single(sink.Writes, w => w.LogLevel == LogLevel.Warning);
        Assert.Contains("dependencies", warning.Formatter!(warning.State, null)!);
    }
 
    [Fact]
    public void EnsureObject_LogsWarning_WhenReplacingScalarWithObject()
    {
        var existing = """
            {
              "name": "my-app",
              "engines": "node >= 16"
            }
            """;
 
        var scaffold = """
            {
              "engines": {
                "node": "^20.19.0 || ^22.13.0 || >=24"
              }
            }
            """;
 
        var sink = new TestSink();
        var logger = new TestLogger("test", sink, enabled: true);
 
        PackageJsonMerger.Merge(existing, scaffold, logger);
 
        var warning = Assert.Single(sink.Writes, w => w.LogLevel == LogLevel.Warning);
        Assert.Contains("engines", warning.Formatter!(warning.State, null)!);
    }
 
    [Fact]
    public void EnsureObject_DoesNotLogWarning_WhenPropertyIsAlreadyObject()
    {
        var existing = """
            {
              "name": "my-app",
              "dependencies": {
                "express": "^4.18.0"
              }
            }
            """;
 
        var scaffold = """
            {
              "dependencies": {
                "vscode-jsonrpc": "^8.2.0"
              }
            }
            """;
 
        var sink = new TestSink();
        var logger = new TestLogger("test", sink, enabled: true);
 
        PackageJsonMerger.Merge(existing, scaffold, logger);
 
        Assert.DoesNotContain(sink.Writes, w => w.LogLevel == LogLevel.Warning);
    }
 
    [Fact]
    public void EnsureObject_DoesNotLogWarning_WhenPropertyIsMissing()
    {
        var existing = """
            {
              "name": "my-app"
            }
            """;
 
        var scaffold = """
            {
              "dependencies": {
                "vscode-jsonrpc": "^8.2.0"
              }
            }
            """;
 
        var sink = new TestSink();
        var logger = new TestLogger("test", sink, enabled: true);
 
        PackageJsonMerger.Merge(existing, scaffold, logger);
 
        Assert.DoesNotContain(sink.Writes, w => w.LogLevel == LogLevel.Warning);
    }
 
    [Fact]
    public void BrownfieldViteProject_AspireOnlyScaffold_MergesCorrectly()
    {
        // Simulates the full brownfield flow where the scaffold only contains
        // Aspire-desired content (no echo of existing). This verifies the
        // double-merge ordering dependency (item 3) is resolved: the merger
        // does not produce incorrect aspire:-prefixed scripts from existing content.
        var existing = """
            {
              "name": "vite-brownfield",
              "version": "2.0.0",
              "type": "module",
              "scripts": {
                "dev": "vite",
                "build": "vite build",
                "preview": "vite preview"
              },
              "dependencies": {
                "vue": "^3.5.0"
              },
              "devDependencies": {
                "vite": "^7.0.0",
                "typescript": "^5.0.0"
              }
            }
            """;
 
        // Scaffold only has Aspire entries — no echo of existing content
        var scaffold = """
            {
              "scripts": {
                "aspire:start": "aspire run",
                "aspire:build": "tsc -p tsconfig.apphost.json",
                "aspire:dev": "tsc --watch -p tsconfig.apphost.json",
                "aspire:lint": "eslint apphost.ts"
              },
              "dependencies": {
                "vscode-jsonrpc": "^8.2.0"
              },
              "devDependencies": {
                "@types/node": "^22.0.0",
                "eslint": "^10.0.3",
                "nodemon": "^3.1.14",
                "tsx": "^4.21.0",
                "typescript": "^5.9.3",
                "typescript-eslint": "^8.57.1"
              },
              "engines": {
                "node": "^20.19.0 || ^22.13.0 || >=24"
              }
            }
            """;
 
        var result = MergeJson(existing, scaffold);
        var doc = JsonNode.Parse(result)!.AsObject();
 
        // Existing metadata preserved
        Assert.Equal("vite-brownfield", doc["name"]!.GetValue<string>());
        Assert.Equal("2.0.0", doc["version"]!.GetValue<string>());
        Assert.Equal("module", doc["type"]!.GetValue<string>());
 
        // Existing scripts preserved
        var scripts = doc["scripts"]!.AsObject();
        Assert.Equal("vite", scripts["dev"]?.GetValue<string>());
        Assert.Equal("vite build", scripts["build"]?.GetValue<string>());
        Assert.Equal("vite preview", scripts["preview"]?.GetValue<string>());
 
        // Aspire scripts added (no incorrect aspire:dev duplicate from old "dev":"vite")
        Assert.Equal("aspire run", scripts["aspire:start"]?.GetValue<string>());
        Assert.Equal("tsc -p tsconfig.apphost.json", scripts["aspire:build"]?.GetValue<string>());
        Assert.Equal("tsc --watch -p tsconfig.apphost.json", scripts["aspire:dev"]?.GetValue<string>());
        Assert.Equal("eslint apphost.ts", scripts["aspire:lint"]?.GetValue<string>());
 
        // No spurious aspire-prefixed duplicates of existing scripts
        Assert.False(scripts.ContainsKey("aspire:preview"));
 
        // Existing deps preserved, Aspire deps added
        Assert.Equal("^3.5.0", GetDep(result, "dependencies", "vue"));
        Assert.Equal("^8.2.0", GetDep(result, "dependencies", "vscode-jsonrpc"));
 
        // Existing devDeps: vite preserved, typescript upgraded to Aspire's version (newer)
        Assert.Equal("^7.0.0", GetDep(result, "devDependencies", "vite"));
        Assert.Equal("^5.9.3", GetDep(result, "devDependencies", "typescript"));
        Assert.Equal("^4.21.0", GetDep(result, "devDependencies", "tsx"));
 
        // Engines set
        Assert.Contains(">=24", doc["engines"]?["node"]?.GetValue<string>());
    }
}