Table of Contents

Testing with the fake gateway

OpenShell.Net.Testing provides FakeOpenShellClient, an in-memory gateway that implements every IOpenShellClient area with the same error kinds as the real client. It keeps workspace-scoped state, paginates with real page tokens, advances sandbox phases on a TimeProvider you control, and streams watch events with cursors. FakeGateway (fake.Gateway) scripts command results, injects failures, and records the calls.

dotnet add package OpenShell.Net.Testing

All the examples on this page run in this documentation's build, against the real fake.

Write code against the interfaces

Depend on IOpenShellClient, or on a per-area interface such as ISandboxClient or IExecClient, so a test can pass the fake:

/// <summary>Application code depends on the interface, so a test can pass the fake.</summary>
internal sealed class BuildRunner(IOpenShellClient client)
{
    public async Task<bool> RunTestsAsync(string sandbox, CancellationToken cancellationToken = default)
    {
        await client.Sandboxes.WaitReadyAsync(sandbox, cancellationToken: cancellationToken);
        var result = await client.Exec.RunAsync(sandbox, ["make", "test"], cancellationToken: cancellationToken);
        return result.Succeeded;
    }
}

Script command results

OnExec registers a handler that sees each command (FakeExecInvocation: workspace, sandbox, argument list, options, and CommandLine) and returns a FakeExecScript, or null to pass. Handlers registered later run first; commands nobody scripts exit with code 0 and no output. Gateway.Calls lists every operation in order (FakeCall: sequence, operation name such as Sandboxes.Create or Exec.Run, workspace, and target).

using var fake = new FakeOpenShellClient();

// Handlers run newest first; return null to let the next one (or the default, exit 0) answer.
fake.Gateway.OnExec(call => call.CommandLine == "make test"
    ? FakeExecScript.Failure(2, "1 test failed\n")
    : null);

await fake.Sandboxes.CreateAsync("ci", new SandboxSpec { Image = "img" });
var passed = await new BuildRunner(fake).RunTestsAsync("ci");

Check(!passed, "the scripted failure was not reported");
Check(fake.Gateway.Calls.Any(c => c.Operation == "Exec.Run" && c.Target == "ci"), "the exec was not recorded");

Control time

Sandboxes move through their phases on the fake's TimeProvider. With the default delays of zero they are Ready at once; set ProvisioningDelay, TransitionDelay, or DeletionDelay in FakeGatewayOptions and pass a FakeTimeProvider (from the Microsoft.Extensions.TimeProvider.Testing package) to test what happens in between. SetPhase forces a phase directly.

var time = new FakeTimeProvider();
using var fake = new FakeOpenShellClient(new FakeGatewayOptions
{
    TimeProvider = time,
    ProvisioningDelay = TimeSpan.FromSeconds(30),
});

await fake.Sandboxes.CreateAsync("slow", new SandboxSpec { Image = "img" });
Check((await fake.Sandboxes.GetAsync("slow")).Phase == SandboxPhase.Provisioning, "expected Provisioning");

// Moving the clock fires the scheduled transition; no real time passes.
time.Advance(TimeSpan.FromSeconds(30));
Check((await fake.Sandboxes.GetAsync("slow")).Phase == SandboxPhase.Ready, "expected Ready");

// Or set a phase directly, for example to test error handling.
fake.Gateway.SetPhase("slow", SandboxPhase.Error);

Waits use the same clock: a WaitReadyAsync with a FakeTimeProvider completes when you advance the clock past the delay, and times out when you advance it past the wait's timeout.

Simulate failures

FailNext makes the next matching calls fail with the exception you give. The operation is an exact name (Sandboxes.Get), a prefix pattern (Sandboxes.*), or *; the call is recorded before it fails. ClearFailures removes pending failures.

using var fake = new FakeOpenShellClient();
await fake.Sandboxes.CreateAsync("demo", new SandboxSpec { Image = "img" });

// The next two Sandboxes.Get calls fail; the third succeeds.
fake.Gateway.FailNext("Sandboxes.Get", new OpenShellException(OpenShellErrorKind.Unavailable, "gateway down"), times: 2);

for (var attempt = 1; ; attempt++)
{
    try
    {
        await fake.Sandboxes.GetAsync("demo");
        Check(attempt == 3, $"succeeded on attempt {attempt}");
        break;
    }
    catch (OpenShellException e) when (e.IsTransient)
    {
        // Your retry logic under test.
    }
}

// The fake reports the same error kinds as the real client.
try
{
    await fake.Sandboxes.GetAsync("missing");
    Check(false, "expected NotFound");
}
catch (OpenShellException e) when (e.IsNotFound)
{
}

Other switches in FakeGatewayOptions: SupportsSuspendResume and SandboxUpdatesEnabled (to test the Conflict paths), AutoCreateWorkspaces, DefaultPageSize, RootfsMaxBytes, GatewayVersion, and BuiltinProfiles.

Logs, events, and watches

EmitLog and EmitEvent add log lines and platform events to a sandbox; they reach GetLogsAsync and every watcher, with cursors.

using var fake = new FakeOpenShellClient();
await fake.Sandboxes.CreateAsync("demo", new SandboxSpec { Image = "img" });

fake.Gateway.EmitLog("demo", "starting");
fake.Gateway.EmitLog("demo", "disk almost full", level: "WARN");

var logs = await fake.Watch.GetLogsAsync("demo", new LogOptions { MinLevel = "WARN" });
Check(logs.Lines.Single().Message == "disk almost full", "expected only the warning");

// Watches replay and follow, with cursors, until you cancel.
using var cts = new CancellationTokenSource();
var messages = new List<string>();
await foreach (var e in fake.Watch.WatchLogsAsync("demo", new WatchOptions { FollowLogs = true, LogTailLines = 10 }, cts.Token))
{
    if (e is LogReceived log)
    {
        messages.Add(log.Line.Message);
        if (messages.Count == 2)
        {
            break;
        }
    }
}

Check(messages.SequenceEqual(["starting", "disk almost full"]), string.Join(", ", messages));

Port forwarding and SSH

OnForward plays the sandbox side of TCP forwards and SSH tunnels (IsSsh): it reads what the client sent and writes the answer. The default handler echoes. ListenAsync on the fake records the listener without opening a socket.

using var fake = new FakeOpenShellClient();
await fake.Sandboxes.CreateAsync("demo", new SandboxSpec { Image = "img" });

// Play the sandbox side of TCP forwards: read what the client sent, write the answer.
fake.Gateway.OnForward(async (request, sandboxSide, cancellationToken) =>
{
    using var reader = new StreamReader(sandboxSide, Encoding.UTF8, leaveOpen: true);
    var question = await reader.ReadToEndAsync(cancellationToken);
    await sandboxSide.WriteAsync(Encoding.UTF8.GetBytes($"port {request.Port}: {question.ToUpperInvariant()}"), cancellationToken);
});

await using var stream = await fake.Tcp.ForwardAsync("demo", 8080);
await stream.WriteAsync(Encoding.UTF8.GetBytes("ping"));
await stream.CompleteWriteAsync();

using var answer = new StreamReader(stream, Encoding.UTF8);
Check(await answer.ReadToEndAsync() == "port 8080: PING", "unexpected forward answer");

Gateway.InteractiveInput records what interactive sessions sent: stdin, resizes, and end of input.

Files

client.Files on the fake reads and writes an in-memory file system per sandbox, which PutFile, ReadFile, and ListFiles on the gateway seed and inspect.

using var fake = new FakeOpenShellClient();
await fake.Sandboxes.CreateAsync("demo", new SandboxSpec { Image = "img" });

// Seed the sandbox's in-memory file system, run the code under test, and inspect the result.
fake.Gateway.PutFile("demo", "/workspace/input.txt", Encoding.UTF8.GetBytes("hello"));

using var input = new MemoryStream();
await fake.Files.DownloadAsync("demo", "/workspace/input.txt", input);
using var output = new MemoryStream(Encoding.UTF8.GetBytes(Encoding.UTF8.GetString(input.ToArray()).ToUpperInvariant()));
await fake.Files.UploadAsync("demo", output, "/workspace/output.txt");

Check(Encoding.UTF8.GetString(fake.Gateway.ReadFile("demo", "/workspace/output.txt")!) == "HELLO", "unexpected file content");
Check(fake.Gateway.ListFiles("demo").Contains("/workspace/output.txt"), "the uploaded file is not listed");

Several workspaces

Clients created over one FakeGateway share its state; each is bound to a workspace, as InWorkspace views are.

// One in-memory gateway, two clients bound to different workspaces.
using var gateway = new FakeGateway();
using var teamA = new FakeOpenShellClient(gateway, "team-a");
using var teamB = new FakeOpenShellClient(gateway, "team-b");

await teamA.Sandboxes.CreateAsync("shared-name", new SandboxSpec { Image = "img" });
await teamB.Sandboxes.CreateAsync("shared-name", new SandboxSpec { Image = "img" });   // no conflict

var all = await teamA.Sandboxes.List(new ListOptions { AllWorkspaces = true }).ToListAsync();
Check(all.Count == 2, $"expected 2 sandboxes, found {all.Count}");

Endpoint statuses

ReportEndpointStatuses sets what the sandbox supervisor reports for its endpoints, for testing token-grant diagnostics:

using var fake = new FakeOpenShellClient();
await fake.Sandboxes.CreateAsync("demo", new SandboxSpec { Image = "img" });

fake.Gateway.ReportEndpointStatuses("demo",
[
    new EndpointStatus
    {
        Host = "api.example.com",
        Ports = [443],
        LastResult = EndpointResult.CredentialUnavailable,
        LastFailure = new EndpointFailure { Code = "token_exchange_failed", Stage = "token_grant" },
    },
]);

var sandbox = await fake.Sandboxes.GetAsync("demo");
Check(sandbox.EndpointStatuses.Single().LastFailure?.Code == "token_exchange_failed", "status not reported");

Differences from a real gateway

The fake auto-creates unknown workspaces by default (AutoCreateWorkspaces), lets a global setting override a sandbox value, fails deleting a provider that a sandbox uses with Conflict, returns redacted provider credentials from listings, and runs no commands: exec results come from your OnExec handlers, and files live in memory instead of being sent with tar.