Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/Grace.CLI.Tests/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Grace.CLI.Tests Agents Guide

Read `../AGENTS.md` for global expectations before updating CLI tests.

## Purpose
- Validate CLI-specific helpers and behaviors without requiring server access.
- Prefer unit tests that exercise parsing, redaction, and history storage logic directly.

## Key Patterns
1. Keep tests isolated: back up and restore any files touched under `~/.grace`.
2. Use FsUnit for assertions and FsCheck for property-based coverage when appropriate.
3. Prefer deterministic timestamps for history-related tests.

## Validation
- Run `dotnet test --no-build` after changes.
- If build issues appear, run `dotnet build --configuration Release` first.
44 changes: 44 additions & 0 deletions src/Grace.CLI.Tests/Grace.CLI.Tests.fsproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<GenerateProgramFile>false</GenerateProgramFile>
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
<SuppressNETCoreSdkPreviewMessage>true</SuppressNETCoreSdkPreviewMessage>
<OtherFlags>--test:GraphBasedChecking</OtherFlags>
<OtherFlags>--test:ParallelOptimization</OtherFlags>
<OtherFlags>--test:ParallelIlxGen</OtherFlags>
</PropertyGroup>

<ItemGroup>
<Compile Include="HistoryStorage.Tests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="FsCheck" Version="3.2.0" />
<PackageReference Include="FsCheck.NUnit" Version="3.2.0" />
<PackageReference Include="FsUnit" Version="7.1.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.2.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.11.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Grace.CLI\Grace.CLI.fsproj" />
<ProjectReference Include="..\Grace.Shared\Grace.Shared.fsproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Update="FSharp.Core" Version="10.0.100" />
</ItemGroup>
</Project>
156 changes: 156 additions & 0 deletions src/Grace.CLI.Tests/HistoryStorage.Tests.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
namespace Grace.CLI.Tests

open FsUnit
open Grace.CLI
open Grace.Shared
open Grace.Shared.Client
open Grace.Shared.Utilities
open NodaTime
open NUnit.Framework
open System
open System.IO
open System.Text.Json

[<NonParallelizable>]
module HistoryStorageTests =

let private withFileBackup (path: string) (action: unit -> unit) =
let backupPath = path + ".testbackup"
let hadExisting = File.Exists(path)

if hadExisting then File.Copy(path, backupPath, true)

try
action ()
finally
if hadExisting then
File.Copy(backupPath, path, true)
File.Delete(backupPath)
elif File.Exists(path) then
File.Delete(path)

let private randomString (random: Random) =
let length = random.Next(8, 32)
let chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
let buffer = Array.zeroCreate<char> length

for i in 0 .. length - 1 do
buffer[i] <- chars[random.Next(chars.Length)]

String(buffer)

[<Test>]
let ``redacts --token values`` () =
let config = UserConfiguration.UserConfiguration()
let random = Random(1337)

for _ in 0..49 do
let value = randomString random
let args = [| "--token"; value |]
let redacted, _ = HistoryStorage.redactArguments args config.History
redacted |> Array.exists (fun arg -> arg = value) |> should equal false

[<Test>]
let ``redacts --token=value values`` () =
let config = UserConfiguration.UserConfiguration()
let random = Random(7331)

for _ in 0..49 do
let value = randomString random
let args = [| $"--token={value}" |]
let redacted, _ = HistoryStorage.redactArguments args config.History

redacted
|> Array.exists (fun arg -> arg.Contains(value, StringComparison.Ordinal))
|> should equal false

[<TestCase("30s", 30.0)>]
[<TestCase("10m", 600.0)>]
[<TestCase("24h", 86400.0)>]
[<TestCase("7d", 604800.0)>]
let ``parses valid durations`` (input: string, expectedSeconds: float) =
match HistoryStorage.tryParseDuration input with
| Ok duration -> duration.TotalSeconds |> should equal expectedSeconds
| Error error -> Assert.Fail($"Expected Ok, got Error: {error}")

[<TestCase("")>]
[<TestCase("10x")>]
[<TestCase("abc")>]
[<TestCase("10")>]
let ``rejects invalid durations`` (input: string) =
match HistoryStorage.tryParseDuration input with
| Ok _ -> Assert.Fail("Expected Error, got Ok.")
| Error _ -> Assert.Pass()

[<Test>]
let ``readHistoryEntries skips corrupt lines`` () =
let historyPath = HistoryStorage.getHistoryFilePath ()
let historyDir = Path.GetDirectoryName(historyPath)
Directory.CreateDirectory(historyDir) |> ignore

let options = JsonSerializerOptions(Constants.JsonSerializerOptions)
options.WriteIndented <- false

let entry: HistoryStorage.HistoryEntry =
{ id = Guid.NewGuid()
timestampUtc = getCurrentInstant ()
argvOriginal = [| "branch"; "status" |]
argvNormalized = [| "branch"; "status" |]
commandLine = "branch status"
cwd = Environment.CurrentDirectory
repoRoot = None
repoName = None
repoBranch = None
graceVersion = "0.1"
exitCode = 0
durationMs = 10L
parseSucceeded = true
redactions = List.empty
source = None }

let json = JsonSerializer.Serialize(entry, options)

withFileBackup historyPath (fun () ->
File.WriteAllLines(historyPath, [| json; "not json" |])
let result = HistoryStorage.readHistoryEntries ()
result.Entries.Length |> should equal 1
result.CorruptCount |> should equal 1)

[<Test>]
let ``prunes history to max entries`` () =
let historyPath = HistoryStorage.getHistoryFilePath ()
let configPath = UserConfiguration.getUserConfigurationPath ()
let historyDir = Path.GetDirectoryName(historyPath)
Directory.CreateDirectory(historyDir) |> ignore

withFileBackup configPath (fun () ->
withFileBackup historyPath (fun () ->
let configuration = UserConfiguration.UserConfiguration()
configuration.History.Enabled <- true
configuration.History.MaxEntries <- 2
configuration.History.MaxFileBytes <- 1024L * 1024L
configuration.History.RetentionDays <- 365

match UserConfiguration.saveUserConfiguration configuration with
| Ok _ -> ()
| Error error -> Assert.Fail(error)

File.WriteAllText(historyPath, String.Empty)

let start = getCurrentInstant ()

for offset in [ 0..2 ] do
let timestamp = start.Plus(Duration.FromMinutes(float offset))

HistoryStorage.tryRecordInvocation
{ argvOriginal = [| "branch"; "status" |]
argvNormalized = [| "branch"; "status" |]
cwd = Environment.CurrentDirectory
exitCode = 0
durationMs = 5L
parseSucceeded = true
timestampUtc = timestamp
source = None }

let result = HistoryStorage.readHistoryEntries ()
result.Entries.Length |> should equal 2))
5 changes: 5 additions & 0 deletions src/Grace.CLI.Tests/Program.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace Grace.CLI.Tests

module Program =
[<EntryPoint>]
let main _ = 0
3 changes: 3 additions & 0 deletions src/Grace.CLI/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ Read `../AGENTS.md` for global expectations before updating CLI code.
2. Preserve existing option names/switches; introduce new aliases when expanding behavior instead of breaking existing scripts.
3. Capture new command patterns or usage tips in this document to guide future agents.

## Recent Patterns
- `grace history` commands operate without requiring a repo `graceconfig.json`; avoid `Configuration.Current()` in history-related flows.

## Validation
- Add option parsing tests and handler unit tests for new functionality.
- Manually exercise impacted commands when practical and ensure `dotnet build --configuration Release` stays green.
Expand Down
17 changes: 7 additions & 10 deletions src/Grace.CLI/Command/Branch.CLI.fs
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,7 @@ module Branch =
type ListContents() =
inherit AsynchronousCommandLineAction()

override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task<int> =
override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) =
task {
try
if parseResult |> verbose then printParseResult parseResult
Expand All @@ -712,18 +712,18 @@ module Branch =
match validateIncomingParameters with
| Ok _ ->
let referenceId =
if isNull (parseResult.GetResult(Options.referenceId)) then
if isNull (parseResult.GetResult Options.referenceId) then
String.Empty
else
parseResult.GetValue(Options.referenceId).ToString()
(parseResult.GetValue Options.referenceId).ToString()

let sha256Hash =
if isNull (parseResult.GetResult(Options.sha256Hash)) then
if isNull (parseResult.GetResult Options.sha256Hash) then
String.Empty
else
parseResult.GetValue(Options.sha256Hash)
parseResult.GetValue Options.sha256Hash

let forceRecompute = parseResult.GetValue(Options.forceRecompute)
let forceRecompute = parseResult.GetValue Options.forceRecompute

let sdkParameters =
ListContentsParameters(
Expand Down Expand Up @@ -751,18 +751,15 @@ module Branch =
.StartAsync(fun progressContext ->
task {
let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]")

let! response = Branch.ListContents(sdkParameters)
t0.Increment(100.0)
t0.Value <- 100.0
return response
})
else
Branch.ListContents(sdkParameters)

match result with
| Ok returnValue ->
let! _ = readGraceStatusFile ()

let directoryVersions =
returnValue.ReturnValue
.Select(fun directoryVersionDto -> directoryVersionDto.DirectoryVersion)
Expand Down
5 changes: 0 additions & 5 deletions src/Grace.CLI/Command/Connect.CLI.fs
Original file line number Diff line number Diff line change
Expand Up @@ -232,11 +232,6 @@ module Connect =

// Download the .zip file to temp directory.
let blobClient = BlobClient(uriWithSharedAccessSignature)
//let zipFilePath = Path.Combine(Current().GraceDirectory, $"{branchDto.LatestPromotion.DirectoryId}.zip")
//let! downloadResponse = blobClient.DownloadToAsync(zipFilePath)

//if downloadResponse.Status = 200 then
// AnsiConsole.MarkupLine $"[{Colors.Important}]Successfully downloaded zip file to {zipFilePath}.[/]"

// Loop through the ZipArchiveEntry list, identify if each file version is binary, and extract
// each one accordingly.
Expand Down
Loading