using System.Xml.Linq; namespace Monitor.Backend.Tests; /// /// 依赖规则(docs/project-layout.md 第 3 节):由测试在构建期强制,等价于文档里的 eng/check-deps。 /// 读取仓库里所有 .csproj 和源文件,违反规则就失败。 /// public sealed class ArchitectureTests { private static readonly string s_root = FindRoot(); private static readonly string[] s_allowedAnalyzers = ["Microsoft.CodeAnalysis.BannedApiAnalyzers"]; private sealed record Project(string Name, string Path, string Directory, IReadOnlyList ProjectReferences, IReadOnlyList Packages); private static string FindRoot() { string? dir = AppContext.BaseDirectory; while (dir is not null && !File.Exists(Path.Combine(dir, "global.json"))) { dir = Path.GetDirectoryName(dir); } return dir ?? throw new InvalidOperationException("repository root not found"); } private static List LoadProjects() => [.. Directory.EnumerateFiles(s_root, "*.csproj", SearchOption.AllDirectories) .Where(p => !p.Contains(Path.DirectorySeparatorChar + "artifacts" + Path.DirectorySeparatorChar, StringComparison.Ordinal) && !p.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal)) .Select(path => { XDocument doc = XDocument.Load(path); IEnumerable items = doc.Descendants("ItemGroup").Elements(); return new Project( Path.GetFileNameWithoutExtension(path), path, Path.GetDirectoryName(path)!, [.. items.Where(e => e.Name == "ProjectReference").Select(e => Path.GetFileNameWithoutExtension(NormalizePath((string?)e.Attribute("Include") ?? "")))], [.. items.Where(e => e.Name == "PackageReference").Select(e => (string?)e.Attribute("Include") ?? "")]); })]; private static string NormalizePath(string path) => path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); private static bool IsTest(Project p) => p.Path.Contains(Path.DirectorySeparatorChar + "tests" + Path.DirectorySeparatorChar, StringComparison.Ordinal); private static bool IsFleet(Project p) => p.Name.StartsWith("Fleet.", StringComparison.Ordinal) && !IsTest(p); [Fact] public void Fleet_NeverReferencesMonitor_InProjectsOrCode() { foreach (Project project in LoadProjects().Where(IsFleet)) { Assert.DoesNotContain(project.ProjectReferences, r => r.StartsWith("Monitor.", StringComparison.Ordinal)); foreach (string file in Directory.EnumerateFiles(project.Directory, "*.cs", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal))) { string text = File.ReadAllText(file); Assert.False(text.Contains("using Monitor.", StringComparison.Ordinal) || text.Contains("namespace Monitor.", StringComparison.Ordinal), $"{file} refers to Monitor.*"); Assert.False(text.Contains("monitor.", StringComparison.Ordinal) && text.Contains("\"monitor.", StringComparison.Ordinal), $"{file} hard-codes a Monitor method or channel name"); Assert.False(text.Contains("7443", StringComparison.Ordinal), $"{file} hard-codes a Monitor port"); } } } [Fact] public void NodeSideLibraries_HaveNoRuntimePackages_AndUseTheNodeBanList() { string[] nodeSide = ["Fleet.Core", "Fleet.Identity", "Fleet.Rpc", "Monitor.Protocol", "Monitor.Backend.Analysis"]; foreach (Project project in LoadProjects().Where(p => nodeSide.Contains(p.Name))) { Assert.All(project.Packages, package => Assert.Contains(package, s_allowedAnalyzers)); } foreach (string name in new[] { "Fleet.Core", "Fleet.Identity", "Fleet.Rpc" }) { Project project = LoadProjects().Single(p => p.Name == name); Assert.Contains("BannedSymbols.Node.txt", File.ReadAllText(project.Path), StringComparison.Ordinal); } } [Fact] public void Protocol_DependsOnlyOnFleetCoreAndRpc_AndAnalysisOnlyOnProtocol() { List projects = LoadProjects(); Assert.Equal(["Fleet.Core", "Fleet.Rpc"], projects.Single(p => p.Name == "Monitor.Protocol").ProjectReferences.Order().ToArray()); Assert.Equal(["Monitor.Protocol"], projects.Single(p => p.Name == "Monitor.Backend.Analysis").ProjectReferences.ToArray()); } [Fact] public void NodeProcesses_AreCollectorsOnly_NoBackendNoServerLibrariesNoRuntimePackages() { List projects = LoadProjects(); Project Get(string name) => projects.Single(p => p.Name == name); // Agent 是纯采集进程:不引用后端的任何层,也不引用 Fleet 的服务端库 Project agent = Get("Monitor.Agent"); Assert.DoesNotContain(agent.ProjectReferences, r => r.StartsWith("Monitor.Backend", StringComparison.Ordinal) || r.StartsWith("Fleet.Server", StringComparison.Ordinal) || r.StartsWith("Fleet.Enrollment", StringComparison.Ordinal)); Assert.Equal(["Fleet.Hosting", "Fleet.Link", "Monitor.Measurement"], agent.ProjectReferences.Order().ToArray()); // Probe 同理,并且不认识 Backend 和 Agent Project probe = Get("Monitor.Probe"); Assert.Equal(["Fleet.Hosting", "Fleet.Link", "Monitor.Measurement"], probe.ProjectReferences.Order().ToArray()); // 两个节点的共享库只依赖协议 Assert.Equal(["Monitor.Protocol"], Get("Monitor.Measurement").ProjectReferences.ToArray()); // 节点一侧的库没有运行时依赖包,并使用节点禁用列表(不能用 HttpClient、Process、写文件;唯一的写文件豁免在 ConfigStore) foreach (string name in new[] { "Fleet.Link", "Fleet.Hosting", "Monitor.Measurement", "Monitor.Agent", "Monitor.Probe" }) { Project project = Get(name); Assert.All(project.Packages, package => Assert.Contains(package, s_allowedAnalyzers)); string settings = File.ReadAllText(project.Path); for (string? dir = project.Directory; dir is not null && dir.StartsWith(s_root, StringComparison.Ordinal); dir = Path.GetDirectoryName(dir)) { string props = Path.Combine(dir, "Directory.Build.props"); settings += File.Exists(props) ? File.ReadAllText(props) : ""; } Assert.Contains("BannedSymbols.Node.txt", settings, StringComparison.Ordinal); } } [Fact] public void CollectionLayer_KnowsNoBusinessProtocolsOrPlans_OnlyMechanismsAndRawColumns() { // 采集层(协议、积木、Agent、Probe、Fleet)只认识机制(传输、节奏、方向、时间戳)和原始列; // REALITY、Hysteria2、Minecraft 这些业务概念以及由原语编排成的测试方案只存在于 Backend。 string[] collectionRoots = ["shared/Monitor.Protocol", "shared/Monitor.Measurement", "agent/src", "probe/src", "fleet/src"]; string[] forbidden = ["reality", "hysteria", "hy2", "minecraft", "brutal", "PlanSpec", "PhaseSpec"]; foreach (string root in collectionRoots) { string directory = Path.Combine(s_root, root.Replace('/', Path.DirectorySeparatorChar)); foreach (string file in Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal))) { string text = File.ReadAllText(file); foreach (string word in forbidden) { Assert.False(text.Contains(word, StringComparison.OrdinalIgnoreCase), $"{file} mentions '{word}': business concepts belong to the backend only"); } } } // 派生量(丢包率、RTT、抖动)由 Backend 的分析层算:采集层的积木不引用分析层 Assert.DoesNotContain(LoadProjects().Single(p => p.Name == "Monitor.Measurement").ProjectReferences, r => r.Contains("Analysis", StringComparison.Ordinal)); } [Fact] public void BackendLayers_ObeyTheDependencyRules() { List projects = LoadProjects(); Project Get(string name) => projects.Single(p => p.Name == name); // Api 不能引用 Storage(也不引用 Gateway):只调用 Application Assert.DoesNotContain(Get("Monitor.Backend.Api").ProjectReferences, r => r.Contains("Storage", StringComparison.Ordinal) || r.Contains("Gateway", StringComparison.Ordinal)); Assert.Equal(["Monitor.Backend.Application"], Get("Monitor.Backend.Api").ProjectReferences.ToArray()); // Application 不依赖任何下游层 Assert.DoesNotContain(Get("Monitor.Backend.Application").ProjectReferences, r => r.Contains("Storage", StringComparison.Ordinal) || r.Contains("Api", StringComparison.Ordinal) || r.Contains("Gateway", StringComparison.Ordinal)); // Gateway 只通过 Application 与 Fleet.Server 工作 Assert.DoesNotContain(Get("Monitor.Backend.Gateway").ProjectReferences, r => r.Contains("Storage", StringComparison.Ordinal) || r.Contains("Backend.Api", StringComparison.Ordinal)); // Storage 实现 Application 的端口,不认识 Api 和 Gateway Assert.DoesNotContain(Get("Monitor.Backend.Storage.Sqlite").ProjectReferences, r => r.Contains("Backend.Api", StringComparison.Ordinal) || r.Contains("Gateway", StringComparison.Ordinal)); // 只有宿主(组合根)认识所有层 Assert.Equivalent( new[] { "Monitor.Backend.Api", "Monitor.Backend.Gateway", "Monitor.Backend.Storage.Sqlite" }, Get("Monitor.Backend").ProjectReferences.ToArray()); } [Fact] public void BackendPackages_AreLimitedToTheDocumentedSet() { string[] allowed = [ "Microsoft.CodeAnalysis.BannedApiAnalyzers", "Microsoft.Data.Sqlite", "Microsoft.AspNetCore.OpenApi", "Microsoft.Extensions.ApiDescription.Server", "xunit.v3", "Microsoft.Extensions.TimeProvider.Testing", ]; foreach (Project project in LoadProjects()) { Assert.All(project.Packages, package => Assert.Contains(package, allowed)); } } [Fact] public void ApplicationAndApi_NeverTouchSqliteOrTheFileSystemDirectly() { foreach (string layer in new[] { "Monitor.Backend.Application", "Monitor.Backend.Api", "Monitor.Backend.Gateway", "Monitor.Backend.Analysis" }) { string dir = Path.Combine(s_root, "src", layer); foreach (string file in Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal))) { string text = File.ReadAllText(file); Assert.DoesNotContain("Microsoft.Data.Sqlite", text, StringComparison.Ordinal); Assert.DoesNotContain("File.WriteAll", text, StringComparison.Ordinal); Assert.DoesNotContain("new FileStream", text, StringComparison.Ordinal); } } } [Fact] public void NoProductionCode_BuildsSqlByConcatenatingValues() { // 手写的参数化 SQL:只有表名可以来自常量;值一律通过参数 string dir = Path.Combine(s_root, "src", "Monitor.Backend.Storage.Sqlite"); foreach (string file in Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal))) { string text = File.ReadAllText(file); Assert.DoesNotContain("+ id +", text, StringComparison.Ordinal); Assert.DoesNotContain("+ name +", text, StringComparison.Ordinal); Assert.DoesNotContain("+ agent", text, StringComparison.Ordinal); Assert.DoesNotContain("'\" +", text, StringComparison.Ordinal); } } [Fact] public void EveryProject_ImportsTheSharedBuildSettings() { // backend/ 下的 Directory.Build.props 对所有项目生效:严格的警告、BannedSymbols、.editorconfig 的风格规则 string props = File.ReadAllText(Path.Combine(s_root, "Directory.Build.props")); Assert.Contains("true", props, StringComparison.Ordinal); Assert.Contains("true", props, StringComparison.Ordinal); Assert.Contains("BannedSymbols.txt", props, StringComparison.Ordinal); Assert.Contains("DateTime.UtcNow", File.ReadAllText(Path.Combine(s_root, "BannedSymbols.txt")), StringComparison.Ordinal); Assert.Contains("System.Diagnostics.Process", File.ReadAllText(Path.Combine(s_root, "BannedSymbols.Node.txt")), StringComparison.Ordinal); } }