diff --git a/example-tests/Example.Tests/Calculator.fs b/Example.Tests/Calculator.fs similarity index 100% rename from example-tests/Example.Tests/Calculator.fs rename to Example.Tests/Calculator.fs diff --git a/example-tests/Example.Tests/CalculatorTests.fs b/Example.Tests/CalculatorTests.fs similarity index 55% rename from example-tests/Example.Tests/CalculatorTests.fs rename to Example.Tests/CalculatorTests.fs index f05ee54..40f9b66 100644 --- a/example-tests/Example.Tests/CalculatorTests.fs +++ b/Example.Tests/CalculatorTests.fs @@ -6,17 +6,17 @@ open Xunit type CalculatorTests() = [] - [] + [] member _.AddOne_increments() = Assert.Equal(42, Calculator.addOne 41) [] - [] + [] member _.AbsoluteDifference_preserves_order() = Assert.Equal(7, Calculator.absoluteDifference 10 3) [] - [ 0", "year % 100 = 0")>] + [ 0", "year % 100 = 0")>] member _.LeapYear_handles_centuries() = Assert.True(Calculator.isLeapYear 2000) Assert.False(Calculator.isLeapYear 1900) diff --git a/example-tests/Example.Tests/Example.Tests.fsproj b/Example.Tests/Example.Tests.fsproj similarity index 100% rename from example-tests/Example.Tests/Example.Tests.fsproj rename to Example.Tests/Example.Tests.fsproj diff --git a/example-tests/Example.Tests/MutationCaseAttribute.fs b/Example.Tests/MutationCaseAttribute.fs similarity index 100% rename from example-tests/Example.Tests/MutationCaseAttribute.fs rename to Example.Tests/MutationCaseAttribute.fs diff --git a/verify-coverage-mutants.fsx b/verify-coverage-mutants.fsx old mode 100644 new mode 100755 index ed5f988..0c68461 --- a/verify-coverage-mutants.fsx +++ b/verify-coverage-mutants.fsx @@ -1,3 +1,4 @@ +#!/usr/bin/env -S dotnet fsi open System open System.IO open System.Reflection @@ -24,13 +25,14 @@ type Command = type Options = { Configuration: string + ProjectPath: string + BuildArgs: string list NoBuild: bool Command: Command } -let projectPath = "Example.Tests" -let targetFramework = "net10.0" -let projectDirectory = "example-tests" -let testProjectPath = Path.Combine(projectDirectory, "Example.Tests", "Example.Tests.fsproj") +type ProjectInfo = + { RelativeProjectPath: string + AbsoluteProjectPath: string } let fail message = eprintfn "%s" message @@ -76,30 +78,67 @@ let repoRoot = if exitCode <> 0 then fail stderr stdout.Trim() -let makeRelativePath (path: string) = - if Path.IsPathRooted path then Path.GetRelativePath(repoRoot, path) else path - let parseArgs (args: string list) = - let rec loop configuration noBuild remaining = + let usage () = + fail "Usage: verify-coverage-mutants.fsx [--configuration Debug|Release] [--build-arg ...] [--no-build] [--list | --show | --run [id...]]" + + let rec loop configuration projectPath buildArgs noBuild remaining = match remaining with - | [] -> { Configuration = configuration; NoBuild = noBuild; Command = Run [] } - | "--configuration" :: value :: tail -> loop value noBuild tail - | "--no-build" :: tail -> loop configuration true tail - | "--list" :: tail when tail.IsEmpty -> { Configuration = configuration; NoBuild = noBuild; Command = List } - | "--show" :: id :: tail when tail.IsEmpty -> { Configuration = configuration; NoBuild = noBuild; Command = Show id } - | "--run" :: tail -> { Configuration = configuration; NoBuild = noBuild; Command = Run tail } - | value :: tail when not (value.StartsWith "--") -> { Configuration = configuration; NoBuild = noBuild; Command = Run (value :: tail) } - | _ -> fail "Usage: dotnet fsi verify-coverage-mutants.fsx [--configuration Debug|Release] [--no-build] [--list | --show | --run [id...]]" - loop "Debug" false args + | [] -> { Configuration = configuration; ProjectPath = projectPath; BuildArgs = List.rev buildArgs; NoBuild = noBuild; Command = Run [] } + | "--configuration" :: value :: tail -> loop value projectPath buildArgs noBuild tail + | "--build-arg" :: value :: tail -> loop configuration projectPath (value :: buildArgs) noBuild tail + | "--no-build" :: tail -> loop configuration projectPath buildArgs true tail + | "--list" :: tail when tail.IsEmpty -> { Configuration = configuration; ProjectPath = projectPath; BuildArgs = List.rev buildArgs; NoBuild = noBuild; Command = List } + | "--show" :: id :: tail when tail.IsEmpty -> { Configuration = configuration; ProjectPath = projectPath; BuildArgs = List.rev buildArgs; NoBuild = noBuild; Command = Show id } + | "--run" :: tail -> { Configuration = configuration; ProjectPath = projectPath; BuildArgs = List.rev buildArgs; NoBuild = noBuild; Command = Run tail } + | value :: tail when not (value.StartsWith "--") -> { Configuration = configuration; ProjectPath = projectPath; BuildArgs = List.rev buildArgs; NoBuild = noBuild; Command = Run (value :: tail) } + | _ -> usage () + + match args with + | projectPath :: tail when not (projectPath.StartsWith "--") -> loop "Debug" projectPath [] false tail + | _ -> usage () let options = parseArgs (fsi.CommandLineArgs |> Array.skip 1 |> Array.toList) -let assemblyPath = - Path.Combine(repoRoot, projectDirectory, "Example.Tests", "bin", options.Configuration, targetFramework, "Example.Tests.dll") +let ensureWithinRepo (path: string) = + let relativePath = Path.GetRelativePath(repoRoot, path) + if relativePath = ".." || relativePath.StartsWith($"..{Path.DirectorySeparatorChar}") then + fail $"Project path must be inside the repository: {path}" + relativePath + +let loadProjectInfo (projectPath: string) = + let absoluteProjectPath = + if Path.IsPathRooted projectPath then projectPath + else Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, projectPath)) + + if not (File.Exists absoluteProjectPath) then + fail $"Project file not found: {absoluteProjectPath}" + + let relativeProjectPath = ensureWithinRepo absoluteProjectPath + { RelativeProjectPath = relativeProjectPath + AbsoluteProjectPath = absoluteProjectPath } + +let project = + loadProjectInfo options.ProjectPath + +let targetPathForProject (workingDirectory: string) (projectPath: string) = + let exitCode, stdout, stderr = + captureProcess workingDirectory "dotnet" [ "msbuild"; projectPath; "--getProperty:TargetPath"; $"-property:Configuration={options.Configuration}" ] + if exitCode <> 0 then fail stderr + let targetPath = stdout.Trim() + if String.IsNullOrWhiteSpace targetPath then + fail $"MSBuild did not return a TargetPath for {projectPath}." + targetPath + +let assemblyPath = targetPathForProject repoRoot project.AbsoluteProjectPath + +let buildArgs projectPath = + [ "build"; projectPath; "--configuration"; options.Configuration; "--nologo" ] + @ options.BuildArgs let ensureBuilt () = if not options.NoBuild then - let exitCode = runProcess repoRoot "dotnet" [ "build"; testProjectPath; "--configuration"; options.Configuration; "--nologo" ] + let exitCode = runProcess repoRoot "dotnet" (buildArgs project.RelativeProjectPath) if exitCode <> 0 then fail "dotnet build failed." if not (File.Exists assemblyPath) then fail $"Compiled test assembly not found at {assemblyPath}." @@ -209,7 +248,7 @@ let runMutation (mutation: MutationCase) = printfn "==> %s: %s" mutation.Id mutation.TestName let buildExitCode = - runProcess worktreePath "dotnet" [ "build"; testProjectPath; "--configuration"; options.Configuration; "--nologo" ] + runProcess worktreePath "dotnet" (buildArgs project.RelativeProjectPath) let outcome = if buildExitCode <> 0 then @@ -217,7 +256,7 @@ let runMutation (mutation: MutationCase) = BuildFailed else let testExitCode = - runProcess worktreePath "dotnet" [ "test"; testProjectPath; "--configuration"; options.Configuration; "--filter"; testFilter mutation; "--no-build"; "--nologo" ] + runProcess worktreePath "dotnet" [ "test"; project.RelativeProjectPath; "--configuration"; options.Configuration; "--filter"; testFilter mutation; "--no-build"; "--nologo" ] if testExitCode = 0 then printfn "SURVIVED %s" mutation.Id