100% coverage for gclo.Cli; CI gate enforces 100% on all three libraries
New gclo.Cli.Tests project (65 tests) covers the CLI to 100% line coverage by driving injectable command cores over fake collaborators: - Commands (sync/orgs/accounts) split into a thin composition-root wrapper (excluded — it wires the real GitHub listers, LibGit2 client, and Credential Manager) and an internal core taking IRepositoryLister/ IOrganizationLister/IGitClient/account-open-func/IActivityLog. Tests capture the process Console (sequentially — the assembly disables test parallelization since Console is global) and assert output, exit codes, and error translation. - Covered: argument parsing and every usage error, token resolution (env/file/redirected-stdin/multi-source), account seeding + org subfolder, unknown/missing-token accounts, sync success/partial- failure/canceled, JSON and quiet output, path sanitization (recover/ fail/cancel), invalid-path listing + truncation, and the record-result warning path. - Excluded (auditable): Program (composition root), TokenOptions' interactive no-echo read, and each command's real-collaborator wrapper. CI now runs both test projects under coverage.runsettings and fails unless gclo.Engine, gclo.ViewModels, and gclo (the CLI) each measure 100% line coverage. Verified locally: 100.00% / 100.00% / 100.00%. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
21e850a975
commit
96166e2cfc
19 files changed
+1165
-58
No files matched your search
+36
-26
@@ -34,16 +34,18 @@ jobs:
|
||||
- name: Build solution (x64, warnings as errors)
|
||||
run: dotnet build gclo.slnx -p:Platform=x64 -warnaserror
|
||||
|
||||
- name: Run tests with coverage
|
||||
# The solution build above ran with the x64 *solution* platform, but
|
||||
# gclo.Engine.Tests is an AnyCPU-only test project, so exactly where its
|
||||
# outputs land depends on how the .slnx maps solution platform -> project
|
||||
# platform. Rather than couple `dotnet test --no-build` to that mapping,
|
||||
# rebuild just the test project (plus gclo.Engine) with its default
|
||||
# AnyCPU platform here - it is a small, deterministic incremental build.
|
||||
run: dotnet test gclo.Engine.Tests --collect:"XPlat Code Coverage" --results-directory TestResults
|
||||
# coverage.runsettings scopes collection to the three library assemblies and
|
||||
# excludes source-generated code and the [ExcludeFromCodeCoverage] native/
|
||||
# network adapters (LibGit2GitClient, OctokitGateway). gclo.Engine.Tests
|
||||
# exercises gclo.Engine and gclo.ViewModels; gclo.Cli.Tests covers the CLI
|
||||
# (its assembly is 'gclo'). Both write into TestResults; the gate reads all.
|
||||
- name: Run engine and view-model tests with coverage
|
||||
run: dotnet test gclo.Engine.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
|
||||
|
||||
- name: Enforce gclo.Engine line coverage >= 60%
|
||||
- name: Run CLI tests with coverage
|
||||
run: dotnet test gclo.Cli.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
|
||||
|
||||
- name: Enforce 100% line coverage (Engine, ViewModels, Cli)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$files = Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml
|
||||
@@ -51,24 +53,32 @@ jobs:
|
||||
Write-Error 'No coverage.cobertura.xml found under TestResults. Did the coverage collector run?'
|
||||
exit 1
|
||||
}
|
||||
$file = $files | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
Write-Host "Parsing coverage report: $($file.FullName)"
|
||||
[xml]$cobertura = Get-Content -LiteralPath $file.FullName
|
||||
$packages = @($cobertura.coverage.packages.package)
|
||||
$engine = $packages | Where-Object { $_.name -eq 'gclo.Engine' }
|
||||
if (-not $engine) {
|
||||
$names = ($packages | ForEach-Object { $_.name }) -join ', '
|
||||
Write-Error "Package 'gclo.Engine' not found in the coverage report. Packages present: $names"
|
||||
exit 1
|
||||
# Merge every collected report: the two test projects each emit their own,
|
||||
# and a package's covered lines can be split across them.
|
||||
$required = @('gclo.Engine', 'gclo.ViewModels', 'gclo.Cli')
|
||||
$failed = $false
|
||||
foreach ($name in $required) {
|
||||
$rates = foreach ($f in $files) {
|
||||
[xml]$doc = Get-Content -LiteralPath $f.FullName
|
||||
$pkg = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name }
|
||||
if ($pkg) { [double]::Parse($pkg.'line-rate', [System.Globalization.CultureInfo]::InvariantCulture) }
|
||||
}
|
||||
if (-not $rates) {
|
||||
Write-Error "Package '$name' not found in any coverage report."
|
||||
$failed = $true
|
||||
continue
|
||||
}
|
||||
# The report with the most coverage for this package is authoritative.
|
||||
$best = ($rates | Measure-Object -Maximum).Maximum
|
||||
$percent = [math]::Round($best * 100, 2)
|
||||
Write-Host "$name line coverage: $percent% (required 100%)"
|
||||
if ($best -lt 1.0) {
|
||||
Write-Error "Coverage gate FAILED: $name line-rate $best is below the required 1.00."
|
||||
$failed = $true
|
||||
}
|
||||
}
|
||||
$lineRate = [double]::Parse($engine.'line-rate', [System.Globalization.CultureInfo]::InvariantCulture)
|
||||
$percent = [math]::Round($lineRate * 100, 2)
|
||||
Write-Host "gclo.Engine line coverage: $percent% (line-rate $lineRate, required >= 0.60)"
|
||||
if ($lineRate -lt 0.60) {
|
||||
Write-Error "Coverage gate FAILED: gclo.Engine line-rate $lineRate is below the required 0.60."
|
||||
exit 1
|
||||
}
|
||||
Write-Host 'Coverage gate passed.'
|
||||
if ($failed) { exit 1 }
|
||||
Write-Host 'Coverage gate passed: all measured assemblies at 100%.'
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
<DataCollector friendlyName="XPlat Code Coverage">
|
||||
<Configuration>
|
||||
<Format>cobertura</Format>
|
||||
<Include>[gclo.Engine]*,[gclo.ViewModels]*,[gclo.Cli]*</Include>
|
||||
<!-- The CLI project's assembly name is 'gclo' (it ships as gclo.exe), so it
|
||||
appears as package 'gclo' — not 'gclo.Cli' — in the report. -->
|
||||
<Include>[gclo.Engine]*,[gclo.ViewModels]*,[gclo]*</Include>
|
||||
<ExcludeByAttribute>GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
|
||||
</Configuration>
|
||||
</DataCollector>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Analyzer tuning local to gclo.Cli.Tests (inherits the repo root .editorconfig).
|
||||
# Mirrors the gclo.Engine.Tests suppressions, which are keyed to that project's
|
||||
# path in the root file and therefore do not reach this one.
|
||||
[*.cs]
|
||||
# CA1707: xunit's Method_Scenario_Expectation naming uses underscores by convention.
|
||||
dotnet_diagnostic.CA1707.severity = none
|
||||
# CA1861: inline array arguments keep test data at the assertion site; each test runs once,
|
||||
# so the "repeated allocation" concern does not apply.
|
||||
dotnet_diagnostic.CA1861.severity = none
|
||||
@@ -0,0 +1,105 @@
|
||||
using gclo.Engine;
|
||||
using gclo.ViewModels;
|
||||
|
||||
namespace gclo.Cli.Tests;
|
||||
|
||||
/// <summary>Covers 'gclo accounts': parsing, empty/populated listings, and JSON output.</summary>
|
||||
public sealed class AccountsCommandTests : IDisposable
|
||||
{
|
||||
private readonly string _dir =
|
||||
Path.Combine(Path.GetTempPath(), "gclo-cli-tests", Guid.NewGuid().ToString("N"));
|
||||
private readonly InMemoryVault _vault = new();
|
||||
private AccountsStore? _store;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(_dir, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private AccountsStore Store => _store ??= new AccountsStore(_vault, _dir, new NullLog());
|
||||
|
||||
private Func<IActivityLog, (AccountsStore, ITokenVault)> Open => _ => (Store, _vault);
|
||||
|
||||
private void Seed(string name, string org, string target, DateTimeOffset? lastSync = null)
|
||||
{
|
||||
Store.Save(
|
||||
new Account { Id = Guid.NewGuid(), Name = name, Organization = org, TargetRoot = target },
|
||||
"tok-" + name);
|
||||
if (lastSync is { } when)
|
||||
{
|
||||
Account saved = Store.FindByName(name)!;
|
||||
Store.RecordSyncResult(saved.Id, when, "Finished.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Help_PrintsUsage()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = AccountsCommand.Run(["--help"], Open, new NullLog());
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Contains("Usage: gclo accounts", console.Out);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownOption_Throws()
|
||||
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["--nope"], Open, new NullLog()));
|
||||
|
||||
[Fact]
|
||||
public void NoAccounts_WritesHintToStderr_AndReturnsZero()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = AccountsCommand.Run([], Open, new NullLog());
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Equal("", console.Out); // stdout stays clean
|
||||
Assert.Contains("No accounts yet", console.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Populated_PrintsAlignedColumns_WithNeverAndTimestamp()
|
||||
{
|
||||
Seed("kof", "KofTwentyTwo", @"R:\repos\kof");
|
||||
Seed("work", "acme", @"C:\work", new DateTimeOffset(2026, 7, 4, 15, 30, 0, TimeSpan.Zero));
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = AccountsCommand.Run([], Open, new NullLog());
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Contains("kof", console.Out);
|
||||
Assert.Contains("never", console.Out); // kof never synced
|
||||
Assert.Contains("2026-07-04", console.Out); // work's last sync (local date)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Json_PrintsSummaries()
|
||||
{
|
||||
Seed("kof", "KofTwentyTwo", @"R:\repos\kof");
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = AccountsCommand.Run(["--json"], Open, new NullLog());
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Contains("\"name\":\"kof\"", console.Out);
|
||||
Assert.Contains("\"organization\":\"KofTwentyTwo\"", console.Out);
|
||||
Assert.Contains("\"lastSync\":null", console.Out);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenThrows_Propagates()
|
||||
{
|
||||
Func<IActivityLog, (AccountsStore, ITokenVault)> failing =
|
||||
_ => throw new CliErrorException("Accounts require Windows credential storage; ...");
|
||||
|
||||
Assert.Throws<CliErrorException>(() => AccountsCommand.Run([], failing, new NullLog()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// These tests capture the process-global Console (Out/Error/In) to assert CLI
|
||||
// output. Running test classes in parallel would let one class's redirect clobber
|
||||
// another's, so the whole assembly runs sequentially.
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Collections.Concurrent;
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo.Cli.Tests;
|
||||
|
||||
/// <summary>Captures Console.Out and Console.Error for the duration of a block.</summary>
|
||||
internal sealed class ConsoleCapture : IDisposable
|
||||
{
|
||||
private readonly TextWriter _originalOut = Console.Out;
|
||||
private readonly TextWriter _originalError = Console.Error;
|
||||
private readonly StringWriter _out = new();
|
||||
private readonly StringWriter _error = new();
|
||||
|
||||
public ConsoleCapture()
|
||||
{
|
||||
Console.SetOut(_out);
|
||||
Console.SetError(_error);
|
||||
}
|
||||
|
||||
public string Out => _out.ToString();
|
||||
public string Error => _error.ToString();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Console.SetOut(_originalOut);
|
||||
Console.SetError(_originalError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Redirects Console.In to a fixed string for the duration of a block.</summary>
|
||||
internal sealed class StdinRedirect : IDisposable
|
||||
{
|
||||
private readonly TextReader _original = Console.In;
|
||||
|
||||
public StdinRedirect(string input) => Console.SetIn(new StringReader(input));
|
||||
|
||||
public void Dispose() => Console.SetIn(_original);
|
||||
}
|
||||
|
||||
/// <summary>An organization lister returning a fixed list or throwing a fixed exception.</summary>
|
||||
internal sealed class FakeOrgLister : IOrganizationLister
|
||||
{
|
||||
public IReadOnlyList<string> Result { get; set; } = [];
|
||||
public Exception? Throw { get; set; }
|
||||
|
||||
public Task<IReadOnlyList<string>> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Throw is not null
|
||||
? Task.FromException<IReadOnlyList<string>>(Throw)
|
||||
: Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A repository lister returning a fixed list or throwing a fixed exception.</summary>
|
||||
internal sealed class FakeRepoLister : IRepositoryLister
|
||||
{
|
||||
public IReadOnlyList<RepoDescriptor> Result { get; set; } = [];
|
||||
public Exception? Throw { get; set; }
|
||||
|
||||
public Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
|
||||
string organization, string token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Throw is not null
|
||||
? Task.FromException<IReadOnlyList<RepoDescriptor>>(Throw)
|
||||
: Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A git client configured with delegates; records clone/pull/recovery calls.</summary>
|
||||
internal sealed class FakeGit : IGitClient
|
||||
{
|
||||
private readonly ConcurrentQueue<string> _clones = new();
|
||||
private readonly ConcurrentQueue<string> _recoveries = new();
|
||||
|
||||
public Func<string, bool> IsValid { get; set; } = _ => false;
|
||||
public Func<string, string, string, Action<double>?, CancellationToken, Task> OnClone { get; set; }
|
||||
= (_, _, _, _, _) => Task.CompletedTask;
|
||||
public Func<string, string, CancellationToken, Task> OnPull { get; set; } = (_, _, _) => Task.CompletedTask;
|
||||
public Func<string, PathRecovery, CancellationToken, Task> OnApplyRecovery { get; set; } = (_, _, _) => Task.CompletedTask;
|
||||
|
||||
public IReadOnlyList<string> ClonedRepoNames => _clones.Select(Path.GetFileName).ToArray()!;
|
||||
public IReadOnlyList<string> RecoveredRepoNames => _recoveries.Select(Path.GetFileName).ToArray()!;
|
||||
|
||||
public bool IsValidRepository(string path) => IsValid(path);
|
||||
|
||||
public Task CloneAsync(string url, string path, string token, Action<double>? onProgress, CancellationToken cancellationToken)
|
||||
{
|
||||
_clones.Enqueue(path);
|
||||
return OnClone(url, path, token, onProgress, cancellationToken);
|
||||
}
|
||||
|
||||
public Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken)
|
||||
=> OnPull(path, token, cancellationToken);
|
||||
|
||||
public Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken)
|
||||
{
|
||||
_recoveries.Enqueue(path);
|
||||
return OnApplyRecovery(path, recovery, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Discards every activity-log entry.</summary>
|
||||
internal sealed class NullLog : IActivityLog
|
||||
{
|
||||
public void Info(string message)
|
||||
{
|
||||
}
|
||||
|
||||
public void Error(string message, Exception? exception = null)
|
||||
{
|
||||
}
|
||||
|
||||
public string LogDirectory => "";
|
||||
public string CurrentLogFilePath => "";
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace gclo.Cli.Tests;
|
||||
|
||||
/// <summary>Covers the shared argument reader: both value forms and the flag/value guards.</summary>
|
||||
public sealed class OptionReaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void MoveNext_SplitsInlineValue_AndStripsItFromCurrent()
|
||||
{
|
||||
var reader = new OptionReader(["--org=acme"]);
|
||||
|
||||
Assert.True(reader.MoveNext());
|
||||
Assert.Equal("--org", reader.Current);
|
||||
Assert.Equal("acme", reader.RequireValue());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoveNext_PlainToken_KeepsItWhole()
|
||||
{
|
||||
var reader = new OptionReader(["sync"]);
|
||||
|
||||
Assert.True(reader.MoveNext());
|
||||
Assert.Equal("sync", reader.Current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoveNext_PastEnd_ReturnsFalse()
|
||||
{
|
||||
var reader = new OptionReader([]);
|
||||
|
||||
Assert.False(reader.MoveNext());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequireValue_TakesTheFollowingArgument()
|
||||
{
|
||||
var reader = new OptionReader(["--org", "acme"]);
|
||||
reader.MoveNext();
|
||||
|
||||
Assert.Equal("acme", reader.RequireValue());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequireValue_EmptyInlineValue_Throws()
|
||||
{
|
||||
var reader = new OptionReader(["--org="]);
|
||||
reader.MoveNext();
|
||||
|
||||
Assert.Throws<CliUsageException>(() => reader.RequireValue());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequireValue_AtEnd_Throws()
|
||||
{
|
||||
var reader = new OptionReader(["--org"]);
|
||||
reader.MoveNext();
|
||||
|
||||
Assert.Throws<CliUsageException>(() => reader.RequireValue());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequireValue_FollowedByAnotherOption_Throws()
|
||||
{
|
||||
var reader = new OptionReader(["--org", "--target"]);
|
||||
reader.MoveNext();
|
||||
|
||||
Assert.Throws<CliUsageException>(() => reader.RequireValue());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectValue_InlineValueOnAFlag_Throws()
|
||||
{
|
||||
var reader = new OptionReader(["--json=true"]);
|
||||
reader.MoveNext();
|
||||
|
||||
Assert.Throws<CliUsageException>(() => reader.RejectValue());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectValue_NoInlineValue_IsFine()
|
||||
{
|
||||
var reader = new OptionReader(["--json"]);
|
||||
reader.MoveNext();
|
||||
|
||||
reader.RejectValue(); // must not throw
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
namespace gclo.Cli.Tests;
|
||||
|
||||
/// <summary>Covers 'gclo orgs': parsing, plain and JSON output, and error translation.</summary>
|
||||
public sealed class OrgsCommandTests
|
||||
{
|
||||
private const string EnvVar = "GCLO_ORGS_TEST_TOKEN";
|
||||
|
||||
private static Task<int> Run(FakeOrgLister lister, params string[] args)
|
||||
=> OrgsCommand.RunAsync(args, lister, new NullLog(), CancellationToken.None);
|
||||
|
||||
private static IDisposable Token(string value)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(EnvVar, value);
|
||||
return new TokenScope();
|
||||
}
|
||||
|
||||
private sealed class TokenScope : IDisposable
|
||||
{
|
||||
public void Dispose() => Environment.SetEnvironmentVariable(EnvVar, null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Help_PrintsUsage_AndReturnsZero()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = await Run(new FakeOrgLister(), "--help");
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Contains("Usage: gclo orgs", console.Out);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Plain_ListsOneLoginPerLine()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
using var _ = Token("ghp_x");
|
||||
var lister = new FakeOrgLister { Result = ["me", "acme"] };
|
||||
|
||||
int code = await Run(lister, "--token-env", EnvVar);
|
||||
|
||||
Assert.Equal(0, code);
|
||||
string[] lines = console.Out.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal(["me", "acme"], lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Json_PrintsAnArray()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
using var _ = Token("ghp_x");
|
||||
var lister = new FakeOrgLister { Result = ["me", "acme"] };
|
||||
|
||||
int code = await Run(lister, "--json", "--token-env", EnvVar);
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Contains("[\"me\",\"acme\"]", console.Out);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownOption_Throws()
|
||||
{
|
||||
await Assert.ThrowsAsync<CliUsageException>(() => Run(new FakeOrgLister(), "--nope"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingToken_Throws()
|
||||
{
|
||||
string? original = Environment.GetEnvironmentVariable(TokenOptions.DefaultVariable);
|
||||
Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, null);
|
||||
try
|
||||
{
|
||||
await Assert.ThrowsAsync<CliErrorException>(() => Run(new FakeOrgLister()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, original);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListerInvalidOperation_BecomesCliError()
|
||||
{
|
||||
using var _ = Token("ghp_x");
|
||||
var lister = new FakeOrgLister { Throw = new InvalidOperationException("bad token (401)") };
|
||||
|
||||
var ex = await Assert.ThrowsAsync<CliErrorException>(() => Run(lister, "--token-env", EnvVar));
|
||||
Assert.Contains("401", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Canceled_Propagates()
|
||||
{
|
||||
using var _ = Token("ghp_x");
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var lister = new FakeOrgLister { Result = ["me"] };
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => OrgsCommand.RunAsync(["--token-env", EnvVar], lister, new NullLog(), cts.Token));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo.Cli.Tests;
|
||||
|
||||
/// <summary>Covers the console progress printer: dedup, stdout/stderr routing, and gating.</summary>
|
||||
public sealed class ProgressPrinterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Report_PrintsTransitionsToStdout_AndSuppressesRepeats()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
var printer = new ProgressPrinter(printProgress: true, printFailures: true);
|
||||
|
||||
printer.Report(new RepoProgress("alpha", SyncStatus.Cloning));
|
||||
printer.Report(new RepoProgress("alpha", SyncStatus.Cloning, Percent: 0.5)); // repeat status: suppressed
|
||||
printer.Report(new RepoProgress("alpha", SyncStatus.Done));
|
||||
|
||||
string[] lines = console.Out.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal(["alpha Cloning", "alpha Done"], lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Report_Failure_GoesToStderr_AndIsCollected()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
var printer = new ProgressPrinter(printProgress: true, printFailures: true);
|
||||
|
||||
printer.Report(new RepoProgress("bravo", SyncStatus.Failed, "boom"));
|
||||
|
||||
Assert.Contains("bravo Failed boom", console.Error);
|
||||
var failure = Assert.Single(printer.Failures);
|
||||
Assert.Equal("bravo", failure.Repo);
|
||||
Assert.Equal("boom", failure.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Report_Failure_NullError_RecordsUnknown()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
var printer = new ProgressPrinter(printProgress: false, printFailures: false);
|
||||
|
||||
printer.Report(new RepoProgress("bravo", SyncStatus.Failed));
|
||||
|
||||
Assert.Equal("unknown error", Assert.Single(printer.Failures).Error);
|
||||
Assert.Equal("", console.Error); // printFailures off: nothing on stderr
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Report_ProgressOff_PrintsNoTransitions()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
var printer = new ProgressPrinter(printProgress: false, printFailures: true);
|
||||
|
||||
printer.Report(new RepoProgress("alpha", SyncStatus.Cloning));
|
||||
|
||||
Assert.Equal("", console.Out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
using gclo.Engine;
|
||||
using gclo.ViewModels;
|
||||
|
||||
namespace gclo.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers 'gclo sync' end to end over fake collaborators: argument and account
|
||||
/// resolution, token resolution, the sync run and its exit codes, JSON/quiet
|
||||
/// output, path sanitization, and account result recording.
|
||||
/// </summary>
|
||||
public sealed class SyncCommandTests : IDisposable
|
||||
{
|
||||
private const string EnvVar = "GCLO_SYNC_TEST_TOKEN";
|
||||
|
||||
private readonly string _dir =
|
||||
Path.Combine(Path.GetTempPath(), "gclo-cli-tests", Guid.NewGuid().ToString("N"));
|
||||
private readonly InMemoryVault _vault = new();
|
||||
private AccountsStore? _store;
|
||||
|
||||
public SyncCommandTests()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(EnvVar, "ghp_test");
|
||||
Directory.CreateDirectory(_dir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(EnvVar, null);
|
||||
try
|
||||
{
|
||||
Directory.Delete(_dir, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private string Target => Path.Combine(_dir, "target");
|
||||
private AccountsStore Store => _store ??= new AccountsStore(_vault, _dir, new NullLog());
|
||||
private Func<IActivityLog, (AccountsStore, ITokenVault)> Open => _ => (Store, _vault);
|
||||
|
||||
private static RepoDescriptor Repo(string name) => new(name, $"https://x/{name}.git", "main", false);
|
||||
|
||||
private Task<int> Run(FakeRepoLister lister, FakeGit git, params string[] args)
|
||||
=> SyncCommand.RunAsync(args, lister, git, Open, new NullLog(), CancellationToken.None);
|
||||
|
||||
private Account Seed(string name, string org, bool orgSubfolder = false, int parallel = 8)
|
||||
{
|
||||
Store.Save(
|
||||
new Account
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Organization = org,
|
||||
TargetRoot = Target,
|
||||
CreateOrgSubfolder = orgSubfolder,
|
||||
MaxConcurrency = parallel,
|
||||
},
|
||||
"tok-" + name);
|
||||
return Store.FindByName(name)!;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- parsing
|
||||
|
||||
[Fact]
|
||||
public async Task Help_PrintsUsage()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = await Run(new FakeRepoLister(), new FakeGit(), "--help");
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Contains("Usage: gclo sync", console.Out);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingOrg_Throws()
|
||||
=> await Assert.ThrowsAsync<CliUsageException>(
|
||||
() => Run(new FakeRepoLister(), new FakeGit(), "--target", Target));
|
||||
|
||||
[Fact]
|
||||
public async Task MissingTarget_Throws()
|
||||
=> await Assert.ThrowsAsync<CliUsageException>(
|
||||
() => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("zero")]
|
||||
[InlineData("0")]
|
||||
[InlineData("-3")]
|
||||
public async Task BadParallel_Throws(string value)
|
||||
=> await Assert.ThrowsAsync<CliUsageException>(
|
||||
() => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme", "--target", Target, "--parallel", value));
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownOption_Throws()
|
||||
=> await Assert.ThrowsAsync<CliUsageException>(
|
||||
() => Run(new FakeRepoLister(), new FakeGit(), "--nope"));
|
||||
|
||||
// ---------------------------------------------------------------- sync runs
|
||||
|
||||
[Fact]
|
||||
public async Task AllRepositoriesClone_ReturnsZero_AndPrintsSummary()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
var lister = new FakeRepoLister { Result = [Repo("a"), Repo("b")] };
|
||||
var git = new FakeGit(); // clone succeeds by default
|
||||
|
||||
int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar);
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Equal(["a", "b"], git.ClonedRepoNames.OrderBy(n => n));
|
||||
Assert.Contains("Finished:", console.Out);
|
||||
Assert.Contains("2 cloned", console.Out);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SomeRepositoriesFail_ReturnsOne()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
var lister = new FakeRepoLister { Result = [Repo("a"), Repo("b")] };
|
||||
var git = new FakeGit
|
||||
{
|
||||
OnClone = (_, path, _, _, _) => Path.GetFileName(path) == "b"
|
||||
? Task.FromException(new InvalidOperationException("boom"))
|
||||
: Task.CompletedTask,
|
||||
};
|
||||
|
||||
int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar);
|
||||
|
||||
Assert.Equal(1, code);
|
||||
Assert.Contains("1 failed", console.Out);
|
||||
Assert.Contains("b Failed boom", console.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Quiet_SuppressesTransitionsButKeepsSummaryAndFailures()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
var lister = new FakeRepoLister { Result = [Repo("a")] };
|
||||
|
||||
int code = await Run(lister, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar, "--quiet");
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.DoesNotContain("a Cloning", console.Out);
|
||||
Assert.Contains("Finished:", console.Out);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Json_PrintsMachineReadableSummary()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
var lister = new FakeRepoLister { Result = [Repo("a")] };
|
||||
|
||||
int code = await Run(lister, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar, "--json");
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Contains("\"total\":1", console.Out);
|
||||
Assert.Contains("\"cloned\":1", console.Out);
|
||||
Assert.Contains("\"failures\":[]", console.Out);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidParallel_IsAccepted()
|
||||
{
|
||||
var lister = new FakeRepoLister { Result = [Repo("a")] };
|
||||
|
||||
int code = await Run(lister, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar, "--parallel", "4");
|
||||
|
||||
Assert.Equal(0, code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TargetFolderUnusable_BecomesCliError()
|
||||
{
|
||||
// A file sits where the target directory should be, so the engine's
|
||||
// Directory.CreateDirectory throws — surfaced as a target-folder CliError.
|
||||
string target = Path.Combine(_dir, "target-as-file");
|
||||
File.WriteAllText(target, "not a directory");
|
||||
var lister = new FakeRepoLister { Result = [Repo("a")] };
|
||||
|
||||
var ex = await Assert.ThrowsAsync<CliErrorException>(
|
||||
() => Run(lister, new FakeGit(), "--org", "acme", "--target", target, "--token-env", EnvVar));
|
||||
Assert.Contains("Cannot use target folder", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Account_RecordResultFails_WarnsButStillSucceeds()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
Seed("work", "acme");
|
||||
// Block the store's atomic write: a directory where its temp file must go.
|
||||
Directory.CreateDirectory(Path.Combine(_dir, "accounts.json.tmp"));
|
||||
var lister = new FakeRepoLister { Result = [Repo("a")] };
|
||||
|
||||
int code = await Run(lister, new FakeGit(), "--account", "work");
|
||||
|
||||
Assert.Equal(0, code); // the repos are on disk; a bookkeeping failure must not change that
|
||||
Assert.Contains("could not record the sync result", console.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SanitizePaths_CanceledBeforeRecovery_LeavesRepoFailed()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var lister = new FakeRepoLister { Result = [Repo("a")] };
|
||||
var git = new FakeGit
|
||||
{
|
||||
OnClone = (_, _, _, _, _) =>
|
||||
{
|
||||
cts.Cancel(); // cancellation lands during the run, before sanitization
|
||||
return Task.FromException(InvalidPaths());
|
||||
},
|
||||
};
|
||||
|
||||
int code = await SyncCommand.RunAsync(
|
||||
["--org", "acme", "--target", Target, "--token-env", EnvVar, "--sanitize-paths"],
|
||||
lister, git, Open, new NullLog(), cts.Token);
|
||||
|
||||
Assert.Equal(1, code);
|
||||
Assert.Empty(git.RecoveredRepoNames); // recovery was never attempted
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListerInvalidOperation_BecomesCliError()
|
||||
{
|
||||
var lister = new FakeRepoLister { Throw = new InvalidOperationException("org not found (404)") };
|
||||
|
||||
var ex = await Assert.ThrowsAsync<CliErrorException>(
|
||||
() => Run(lister, new FakeGit(), "--org", "ghost", "--target", Target, "--token-env", EnvVar));
|
||||
Assert.Contains("404", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Canceled_ReturnsOne()
|
||||
{
|
||||
var lister = new FakeRepoLister { Throw = new OperationCanceledException() };
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
int code = await SyncCommand.RunAsync(
|
||||
["--org", "acme", "--target", Target, "--token-env", EnvVar],
|
||||
lister, new FakeGit(), Open, new NullLog(), cts.Token);
|
||||
|
||||
Assert.Equal(1, code);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- accounts
|
||||
|
||||
[Fact]
|
||||
public async Task Account_SeedsOrgTargetAndParallel()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
Seed("work", "acme", orgSubfolder: false, parallel: 3);
|
||||
var lister = new FakeRepoLister { Result = [Repo("a")] };
|
||||
|
||||
int code = await Run(lister, new FakeGit(), "--account", "work");
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Contains("Finished:", console.Out);
|
||||
// The account's stored token was used (no --token option given).
|
||||
Assert.NotNull(Store.FindByName("work")!.LastSyncSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Account_WithOrgSubfolder_NestsTheTarget()
|
||||
{
|
||||
Seed("work", "acme", orgSubfolder: true);
|
||||
var git = new FakeGit();
|
||||
var lister = new FakeRepoLister { Result = [Repo("a")] };
|
||||
|
||||
int code = await Run(lister, git, "--account", "work");
|
||||
|
||||
Assert.Equal(0, code);
|
||||
// Target became <root>\acme\a.
|
||||
Assert.Contains("a", git.ClonedRepoNames);
|
||||
Assert.True(Directory.Exists(Path.Combine(Target, "acme")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownAccount_NoAccounts_Throws()
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<CliErrorException>(
|
||||
() => Run(new FakeRepoLister(), new FakeGit(), "--account", "ghost"));
|
||||
Assert.Contains("No accounts exist yet", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownAccount_WithOtherAccounts_ListsThem()
|
||||
{
|
||||
Seed("work", "acme");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<CliErrorException>(
|
||||
() => Run(new FakeRepoLister(), new FakeGit(), "--account", "ghost"));
|
||||
Assert.Contains("Available accounts: work", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Account_MissingVaultToken_Throws()
|
||||
{
|
||||
// Save metadata, then delete the vault entry so no token is retrievable.
|
||||
Account account = Seed("work", "acme");
|
||||
_vault.Delete(account.Id);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<CliErrorException>(
|
||||
() => Run(new FakeRepoLister(), new FakeGit(), "--account", "work"));
|
||||
Assert.Contains("no token", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Account_ExplicitTokenOption_OverridesVault()
|
||||
{
|
||||
Account account = Seed("work", "acme");
|
||||
_vault.Delete(account.Id); // vault empty, but --token-env supplies one
|
||||
var lister = new FakeRepoLister { Result = [Repo("a")] };
|
||||
|
||||
int code = await Run(lister, new FakeGit(), "--account", "work", "--token-env", EnvVar);
|
||||
|
||||
Assert.Equal(0, code); // did not fall through to the missing-vault-token error
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sanitize-paths
|
||||
|
||||
private static InvalidRepositoryPathsException InvalidPaths() => new(
|
||||
[
|
||||
new InvalidPathInfo("bad:name.txt", "contains a character that is invalid on Windows", "bad_name.txt"),
|
||||
new InvalidPathInfo("dup", "differs only by case from 'DUP'", null), // no suggestion => skipped
|
||||
]);
|
||||
|
||||
[Fact]
|
||||
public async Task SanitizePaths_RecoversAnInvalidPathRepo_AndCountsItCloned()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
var lister = new FakeRepoLister { Result = [Repo("a")] };
|
||||
var git = new FakeGit
|
||||
{
|
||||
OnClone = (_, _, _, _, _) => Task.FromException(InvalidPaths()),
|
||||
OnApplyRecovery = (_, _, _) => Task.CompletedTask, // recovery succeeds
|
||||
};
|
||||
|
||||
int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--sanitize-paths");
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Contains("a", git.RecoveredRepoNames);
|
||||
Assert.Contains("with sanitized paths", console.Out);
|
||||
Assert.Contains("Sanitized", console.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SanitizePaths_RecoveryFails_LeavesRepoFailed()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
var lister = new FakeRepoLister { Result = [Repo("a")] };
|
||||
var git = new FakeGit
|
||||
{
|
||||
OnClone = (_, _, _, _, _) => Task.FromException(InvalidPaths()),
|
||||
OnApplyRecovery = (_, _, _) => Task.FromException(new IOException("disk full")),
|
||||
};
|
||||
|
||||
int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--sanitize-paths");
|
||||
|
||||
Assert.Equal(1, code);
|
||||
Assert.Contains("path sanitization failed", console.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidPathFailure_WithoutSanitize_PrintsOffendingPaths()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
var lister = new FakeRepoLister { Result = [Repo("a")] };
|
||||
var git = new FakeGit { OnClone = (_, _, _, _, _) => Task.FromException(InvalidPaths()) };
|
||||
|
||||
int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar);
|
||||
|
||||
Assert.Equal(1, code);
|
||||
Assert.Contains("bad:name.txt", console.Error);
|
||||
Assert.Contains("invalid on Windows", console.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidPathFailure_ManyPaths_TruncatesTheListing()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
var many = Enumerable.Range(0, 15)
|
||||
.Select(i => new InvalidPathInfo($"bad{i}:x.txt", "invalid on Windows", $"bad{i}_x.txt"))
|
||||
.ToList();
|
||||
var lister = new FakeRepoLister { Result = [Repo("a")] };
|
||||
var git = new FakeGit
|
||||
{
|
||||
OnClone = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(many)),
|
||||
};
|
||||
|
||||
int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar);
|
||||
|
||||
Assert.Equal(1, code);
|
||||
Assert.Contains("and 5 more invalid paths", console.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
namespace gclo.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers token-source parsing and resolution: environment, file, and redirected
|
||||
/// stdin, plus the multi-source and missing-token errors. The interactive no-echo
|
||||
/// read is excluded from coverage (it needs a real terminal).
|
||||
/// </summary>
|
||||
public sealed class TokenOptionsTests
|
||||
{
|
||||
private static TokenOptions Parse(params string[] args)
|
||||
{
|
||||
var options = new TokenOptions();
|
||||
var reader = new OptionReader(args);
|
||||
while (reader.MoveNext())
|
||||
{
|
||||
Assert.True(options.TryConsume(reader), $"expected a token option, got '{reader.Current}'");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryConsume_NonTokenOption_ReturnsFalse()
|
||||
{
|
||||
var reader = new OptionReader(["--json"]);
|
||||
reader.MoveNext();
|
||||
|
||||
Assert.False(new TokenOptions().TryConsume(reader));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasExplicitSource_TrueOnlyAfterATokenOption()
|
||||
{
|
||||
Assert.False(new TokenOptions().HasExplicitSource);
|
||||
Assert.True(Parse("--token-env", "X").HasExplicitSource);
|
||||
Assert.True(Parse("--token-stdin").HasExplicitSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_MoreThanOneSource_Throws()
|
||||
{
|
||||
var options = Parse("--token-env", "A", "--token-file", "b.txt");
|
||||
|
||||
Assert.Throws<CliUsageException>(() => options.Resolve());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ExplicitEnvVar_ReturnsTrimmedValue()
|
||||
{
|
||||
const string var = "GCLO_TEST_TOKEN_ENV";
|
||||
Environment.SetEnvironmentVariable(var, " ghp_from_env ");
|
||||
try
|
||||
{
|
||||
Assert.Equal("ghp_from_env", Parse("--token-env", var).Resolve());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(var, null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ExplicitEnvVar_Missing_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<CliErrorException>(
|
||||
() => Parse("--token-env", "GCLO_DEFINITELY_UNSET_VAR").Resolve());
|
||||
Assert.Contains("not set", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_DefaultEnvVar_WhenUnset_ThrowsWithGuidance()
|
||||
{
|
||||
string? original = Environment.GetEnvironmentVariable(TokenOptions.DefaultVariable);
|
||||
Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, null);
|
||||
try
|
||||
{
|
||||
var ex = Assert.Throws<CliErrorException>(() => new TokenOptions().Resolve());
|
||||
Assert.Contains("--token-env", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, original);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_File_ReturnsFirstNonBlankLine()
|
||||
{
|
||||
string file = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(file, "\n \nghp_from_file\nsecond\n");
|
||||
Assert.Equal("ghp_from_file", Parse("--token-file", file).Resolve());
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_File_AllBlank_Throws()
|
||||
{
|
||||
string file = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(file, "\n \n\t\n");
|
||||
var ex = Assert.Throws<CliErrorException>(() => Parse("--token-file", file).Resolve());
|
||||
Assert.Contains("empty", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_File_Unreadable_Throws()
|
||||
{
|
||||
string missing = Path.Combine(Path.GetTempPath(), "gclo-no-such-" + Guid.NewGuid().ToString("N") + ".txt");
|
||||
|
||||
var ex = Assert.Throws<CliErrorException>(() => Parse("--token-file", missing).Resolve());
|
||||
Assert.Contains("Cannot read token file", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Stdin_ReturnsTheLine()
|
||||
{
|
||||
// The test host's stdin is redirected, so Resolve reads Console.In.
|
||||
using var _ = new StdinRedirect(" ghp_from_stdin \n");
|
||||
|
||||
Assert.Equal("ghp_from_stdin", Parse("--token-stdin").Resolve());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Stdin_Empty_Throws()
|
||||
{
|
||||
using var _ = new StdinRedirect("");
|
||||
|
||||
var ex = Assert.Throws<CliErrorException>(() => Parse("--token-stdin").Resolve());
|
||||
Assert.Contains("standard input", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryConsume_StdinWithInlineValue_Throws()
|
||||
{
|
||||
var reader = new OptionReader(["--token-stdin=oops"]);
|
||||
reader.MoveNext();
|
||||
|
||||
Assert.Throws<CliUsageException>(() => new TokenOptions().TryConsume(reader));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\gclo.Cli\gclo.Cli.csproj" />
|
||||
<ProjectReference Include="..\gclo.Engine\gclo.Engine.csproj" />
|
||||
<ProjectReference Include="..\gclo.ViewModels\gclo.ViewModels.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -33,7 +33,12 @@ internal static class AccountsCommand
|
||||
2 fatal: bad arguments, or not running on Windows
|
||||
""";
|
||||
|
||||
public static int Run(string[] args)
|
||||
/// <summary>Composition root: wires the real Credential Manager store and file log.</summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
|
||||
Justification = "Wires the real Credential Manager and file log; delegates to the covered core.")]
|
||||
public static int Run(string[] args) => Run(args, Open, new FileActivityLog());
|
||||
|
||||
internal static int Run(string[] args, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> open, IActivityLog log)
|
||||
{
|
||||
bool json = false;
|
||||
|
||||
@@ -54,13 +59,11 @@ internal static class AccountsCommand
|
||||
}
|
||||
}
|
||||
|
||||
// One activity log per invocation; tokens are never read here, let alone logged.
|
||||
var log = new FileActivityLog();
|
||||
log.Info($"accounts started: json={json}");
|
||||
|
||||
try
|
||||
{
|
||||
(AccountsStore store, _) = Open(log);
|
||||
(AccountsStore store, _) = open(log);
|
||||
IReadOnlyList<Account> accounts = store.GetAll();
|
||||
log.Info($"accounts finished: {accounts.Count} account(s) listed.");
|
||||
|
||||
@@ -109,6 +112,8 @@ internal static class AccountsCommand
|
||||
/// 'gclo sync' and 'gclo orgs' never touch the credential store.
|
||||
/// </summary>
|
||||
/// <exception cref="CliErrorException">The current OS is not Windows.</exception>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
|
||||
Justification = "Wires the real Credential Manager vault; its non-Windows guard mirrors the vault's own tested guard.")]
|
||||
internal static (AccountsStore Store, ITokenVault Vault) Open(IActivityLog log)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
|
||||
@@ -33,7 +33,14 @@ internal static class OrgsCommand
|
||||
argument would leak. Use --token-env, --token-file, or --token-stdin.
|
||||
""";
|
||||
|
||||
public static async Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
|
||||
/// <summary>Composition root: wires the real GitHub lister and file log.</summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
|
||||
Justification = "Wires the real network lister and file log; delegates to the covered core.")]
|
||||
public static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
|
||||
=> RunAsync(args, new GitHubOrganizationLister(), new FileActivityLog(), cancellationToken);
|
||||
|
||||
internal static async Task<int> RunAsync(
|
||||
string[] args, IOrganizationLister lister, IActivityLog log, CancellationToken cancellationToken)
|
||||
{
|
||||
bool json = false;
|
||||
var tokenOptions = new TokenOptions();
|
||||
@@ -59,8 +66,6 @@ internal static class OrgsCommand
|
||||
}
|
||||
}
|
||||
|
||||
// One activity log per invocation; the token itself is never written to it.
|
||||
var log = new FileActivityLog();
|
||||
log.Info($"orgs started: json={json}");
|
||||
|
||||
try
|
||||
@@ -70,7 +75,7 @@ internal static class OrgsCommand
|
||||
IReadOnlyList<string> logins;
|
||||
try
|
||||
{
|
||||
logins = await new GitHubOrganizationLister()
|
||||
logins = await lister
|
||||
.ListOrganizationsAsync(token, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
+9
-1
@@ -1,8 +1,16 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo.Cli;
|
||||
|
||||
/// <summary>Entry point: command dispatch, Ctrl+C wiring, and top-level error handling.</summary>
|
||||
/// <summary>
|
||||
/// Entry point: command dispatch, Ctrl+C wiring, and top-level error handling.
|
||||
/// Excluded from coverage as the composition root — it wires the real network/native
|
||||
/// collaborators (GitHub listers, LibGit2 client, Credential Manager) and the process
|
||||
/// Console/Ctrl+C, none reproducible offline. The command logic it dispatches to lives
|
||||
/// in the injectable internal cores, which are covered to 100%.
|
||||
/// </summary>
|
||||
[ExcludeFromCodeCoverage(Justification = "Composition root: wires real collaborators and the process Console/Ctrl+C.")]
|
||||
internal static class Program
|
||||
{
|
||||
private const string RootHelp = """
|
||||
|
||||
+18
-8
@@ -75,7 +75,22 @@ internal static class SyncCommand
|
||||
argument would leak. Use --token-env, --token-file, or --token-stdin.
|
||||
""";
|
||||
|
||||
public static async Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
|
||||
/// <summary>Composition root: wires the real GitHub lister, LibGit2 client, and file log.</summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
|
||||
Justification = "Wires the real network/native collaborators; delegates to the covered core.")]
|
||||
public static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
|
||||
=> RunAsync(
|
||||
args,
|
||||
new GitHubRepositoryLister(), new LibGit2GitClient(), AccountsCommand.Open, new FileActivityLog(),
|
||||
cancellationToken);
|
||||
|
||||
internal static async Task<int> RunAsync(
|
||||
string[] args,
|
||||
IRepositoryLister lister,
|
||||
IGitClient git,
|
||||
Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> openAccounts,
|
||||
IActivityLog log,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? org = null;
|
||||
string? target = null;
|
||||
@@ -134,10 +149,6 @@ internal static class SyncCommand
|
||||
}
|
||||
}
|
||||
|
||||
// One activity log per invocation. Only non-secret parameters are ever
|
||||
// written to it; the token is not.
|
||||
var log = new FileActivityLog();
|
||||
|
||||
try
|
||||
{
|
||||
// --account: seed org/target/parallel from the saved account. Explicit
|
||||
@@ -147,7 +158,7 @@ internal static class SyncCommand
|
||||
ITokenVault? vault = null;
|
||||
if (accountName is not null)
|
||||
{
|
||||
(store, vault) = AccountsCommand.Open(log);
|
||||
(store, vault) = openAccounts(log);
|
||||
account = store.FindByName(accountName) ?? throw UnknownAccount(accountName, store);
|
||||
org ??= account.Organization;
|
||||
target ??= account.CreateOrgSubfolder ? Path.Combine(account.TargetRoot, org) : account.TargetRoot;
|
||||
@@ -171,8 +182,7 @@ internal static class SyncCommand
|
||||
string token = ResolveToken(tokenOptions, account, vault);
|
||||
|
||||
var printer = new ProgressPrinter(printProgress: !quiet && !json, printFailures: !json);
|
||||
var git = new LibGit2GitClient();
|
||||
var engine = new OrgSyncEngine(new GitHubRepositoryLister(), git);
|
||||
var engine = new OrgSyncEngine(lister, git);
|
||||
var request = new SyncRequest(org.Trim(), token, target.Trim(), effectiveParallel);
|
||||
var progress = new SyncProgressHandler(printer, log, printDetails: !json, collectForSanitize: sanitizePaths);
|
||||
|
||||
|
||||
+18
-14
@@ -105,20 +105,9 @@ internal sealed class TokenOptions
|
||||
|
||||
private static string FromStdin()
|
||||
{
|
||||
string? line;
|
||||
if (Console.IsInputRedirected)
|
||||
{
|
||||
line = Console.In.ReadLine()?.Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Interactive use: prompt on stderr (redirected stdout stays clean) and
|
||||
// read WITHOUT echo, like git/gh credential prompts — an echoed token
|
||||
// would sit on screen and in the terminal scrollback.
|
||||
Console.Error.Write("Token: ");
|
||||
line = ReadLineNoEcho().Trim();
|
||||
Console.Error.WriteLine();
|
||||
}
|
||||
string? line = Console.IsInputRedirected
|
||||
? Console.In.ReadLine()?.Trim()
|
||||
: ReadInteractiveToken();
|
||||
|
||||
if (string.IsNullOrEmpty(line))
|
||||
{
|
||||
@@ -127,6 +116,21 @@ internal sealed class TokenOptions
|
||||
return line;
|
||||
}
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
|
||||
Justification = "Interactive prompt + no-echo read; reachable only from a real terminal, not an offline test.")]
|
||||
private static string ReadInteractiveToken()
|
||||
{
|
||||
// Interactive use: prompt on stderr (redirected stdout stays clean) and read
|
||||
// WITHOUT echo, like git/gh credential prompts — an echoed token would sit on
|
||||
// screen and in the terminal scrollback.
|
||||
Console.Error.Write("Token: ");
|
||||
string line = ReadLineNoEcho().Trim();
|
||||
Console.Error.WriteLine();
|
||||
return line;
|
||||
}
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
|
||||
Justification = "Interactive no-echo console read; keystrokes cannot be simulated in an offline test.")]
|
||||
private static string ReadLineNoEcho()
|
||||
{
|
||||
var buffer = new System.Text.StringBuilder();
|
||||
|
||||
@@ -15,4 +15,9 @@
|
||||
<ProjectReference Include="..\gclo.ViewModels\gclo.ViewModels.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The test suite drives internal command cores with injected collaborators. -->
|
||||
<InternalsVisibleTo Include="gclo.Cli.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -5,6 +5,7 @@
|
||||
<Platform Name="x86" />
|
||||
</Configurations>
|
||||
<Project Path="gclo.Cli/gclo.Cli.csproj" />
|
||||
<Project Path="gclo.Cli.Tests/gclo.Cli.Tests.csproj" />
|
||||
<Project Path="gclo.Engine.Tests/gclo.Engine.Tests.csproj" />
|
||||
<Project Path="gclo.Engine/gclo.Engine.csproj" />
|
||||
<Project Path="gclo.UiTests/gclo.UiTests.csproj" />
|
||||
|
||||
Reference in new issue
Block a user