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
195 changes: 195 additions & 0 deletions tests/OpenCode.Sdk.Tests/Experimental/ExperimentalClientLiveTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
using System.Text.Json.Nodes;
using OpenCode.Sdk.Models;
using OpenCode.Sdk.Tests.Harness;
using OpenCode.Sdk.Tests.Harness.Ownership;
using OpenCode.Sdk.Tests.Sessions.Support;
using OpenCode.Sdk.Tests.Support;

namespace OpenCode.Sdk.Tests.Experimental;

[ClassDataSource<SimulatedDriveServerFixture>(Shared = SharedType.PerTestSession)]
[NotInParallel(ParallelConstraintKeys.ServerProcess)]
public sealed class ExperimentalClientLiveTests(SimulatedDriveServerFixture server)
{
private const string InstructionKey = "sdk-coverage-entry";
private const string InitialValue = "Initial owned instruction";
private const string ReplacementValue = "Replacement owned instruction";

[Test]
[Timeout(180_000)]
public async Task AddMcpServerAsync_Should_Register_The_Disabled_Entry_When_No_Connection_Is_Requested(CancellationToken cancellationToken)
{
await WithMcpEntryAsync(async (client, name, probe) =>
{
var before = await client.McpServers.ListMcpServersAsync(cancellationToken: cancellationToken);
await Assert.That(before.McpServers.Select(item => item.Name)).DoesNotContain(name);

await AddDisabledMcpAsync(client, name, cancellationToken);

// Check finite state before waiting for the event a no-op could never publish.
var after = await client.McpServers.ListMcpServersAsync(cancellationToken: cancellationToken);
await Assert.That(after.McpServers.Select(item => item.Name)).Contains(name);
_ = await WaitForMcpChangeAsync(probe, name, cancellationToken);
await AssertDisabledMcpAsync(client, name, cancellationToken);
}, cancellationToken);
}

[Test]
[Timeout(180_000)]
public async Task RemoveMcpServerAsync_Should_Remove_The_Entry_When_It_Exists(CancellationToken cancellationToken)
{
await WithMcpEntryAsync(async (client, name, probe) =>
{
await AddDisabledMcpAsync(client, name, cancellationToken);
var added = await WaitForMcpChangeAsync(probe, name, cancellationToken);
await AssertDisabledMcpAsync(client, name, cancellationToken);

_ = await client.Experimental.RemoveMcpServerAsync(name, cancellationToken: cancellationToken);

// Check finite state before waiting for the event a no-op could never publish.
var after = await client.McpServers.ListMcpServersAsync(cancellationToken: cancellationToken);
await Assert.That(after.McpServers.Select(item => item.Name)).DoesNotContain(name);
using var barrier = SessionEventProbe.Barrier(cancellationToken);
_ = await probe.WaitForAsync<McpStatusChanged>(item => item.Data.Server == name, "owned MCP removal", added, barrier.Token);
}, cancellationToken);
}

[Test]
[Timeout(60_000)]
public async Task ListSessionInstructionsEntriesAsync_Should_Return_The_Entry_When_It_Was_Set(CancellationToken cancellationToken)
{
await WithInstructionEntryAsync(async (client, sessionId) =>
{
var response = await client.Experimental.ListSessionInstructionsEntriesAsync(sessionId, cancellationToken: cancellationToken);

await Assert.That(response.SessionInstructionsEntries.Select(entry => entry.Key)).Contains(InstructionKey);
await Assert.That(response.SessionInstructionsEntries.Single(entry => entry.Key == InstructionKey).Value.GetString()).IsEqualTo(InitialValue);
}, cancellationToken);
}

[Test]
[Timeout(60_000)]
public async Task RemoveSessionInstructionsEntryAsync_Should_Hide_The_Entry_When_It_Exists(CancellationToken cancellationToken)
{
await WithInstructionEntryAsync(async (client, sessionId) =>
{
var before = await client.Experimental.ListSessionInstructionsEntriesAsync(sessionId, cancellationToken: cancellationToken);
await Assert.That(before.SessionInstructionsEntries.Select(entry => entry.Key)).Contains(InstructionKey);

_ = await client.Experimental.RemoveSessionInstructionsEntryAsync(sessionId, InstructionKey, cancellationToken: cancellationToken);

var after = await client.Experimental.ListSessionInstructionsEntriesAsync(sessionId, cancellationToken: cancellationToken);
await Assert.That(after.SessionInstructionsEntries.Select(entry => entry.Key)).DoesNotContain(InstructionKey);
}, cancellationToken);
}

[Test]
[Timeout(60_000)]
public async Task SetSessionInstructionsEntryAsync_Should_Replace_The_Value_When_The_Key_Already_Exists(CancellationToken cancellationToken)
{
await WithInstructionEntryAsync(async (client, sessionId) =>
{
var before = await client.Experimental.ListSessionInstructionsEntriesAsync(sessionId, cancellationToken: cancellationToken);
await Assert.That(before.SessionInstructionsEntries.Single(entry => entry.Key == InstructionKey).Value.GetString()).IsEqualTo(InitialValue);

await SetInstructionAsync(client, sessionId, ReplacementValue, cancellationToken);

var after = await client.Experimental.ListSessionInstructionsEntriesAsync(sessionId, cancellationToken: cancellationToken);
await Assert.That(after.SessionInstructionsEntries.Single(entry => entry.Key == InstructionKey).Value.GetString()).IsEqualTo(ReplacementValue);
}, cancellationToken);
}

private async Task WithInstructionEntryAsync(Func<OpenCodeClient, string, Task> scenario, CancellationToken token)
{
using var workspace = server.CreateWorkspace();
using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path });
var created = await client.Sessions.CreateSessionAsync(new SessionCreateRequest
{
Title = "experimental-instruction-live",
Location = new LocationPublicRef { Directory = workspace.Path },
}, cancellationToken: token);
var sessionId = created.Session.Id;
var session = client.Sessions.GetSessionClient(sessionId);
var cleanup = new OwnedSessionCleanup(session, TimeSpan.FromSeconds(15));
cleanup.MarkTurnCompleted();
cleanup.Own("instruction entry removal", async cancellation =>
_ = await client.Experimental.RemoveSessionInstructionsEntryAsync(sessionId, InstructionKey, cancellationToken: cancellation));
Exception? primaryFailure = null;
try
{
await SetInstructionAsync(client, sessionId, InitialValue, token);
await scenario(client, sessionId);
}
catch (Exception exception)
{
primaryFailure = exception;
}

await cleanup.CompleteAsync(primaryFailure);
}

private static async Task SetInstructionAsync(OpenCodeClient client, string sessionId, string value, CancellationToken token) =>
_ = await client.Experimental.SetSessionInstructionsEntryAsync(sessionId, InstructionKey,
new ExperimentalSessionInstructionsEntryPutRequest { Value = DriveJson.Element(JsonValue.Create(value)) }, cancellationToken: token);

private async Task WithMcpEntryAsync(Func<OpenCodeClient, string, SessionEventProbe, Task> scenario, CancellationToken token)
{
using var workspace = server.CreateWorkspace();
using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path });
var name = "sdk-disabled-" + Guid.NewGuid().ToString("N");
var cleanup = new OwnedCleanup(TimeSpan.FromSeconds(15));
cleanup.Own("runtime MCP removal", async cancellation => await RemoveOwnedMcpAsync(client, name, cancellation));
var reader = new OwnedEventReader(TimeSpan.FromSeconds(120), TimeSpan.FromSeconds(15), token);
cleanup.Own("MCP event reader", TimeSpan.FromSeconds(75), _ => reader.CompleteAsync(null));
var probe = new SessionEventProbe(reader);
Exception? primaryFailure = null;
try
{
probe.Start(client.Events.SubscribeAsync(cancellationToken: reader.Token));
await probe.WaitForConnectedAsync(token);
await scenario(client, name, probe);
}
catch (Exception exception)
{
primaryFailure = exception;
}

await cleanup.CompleteAsync(primaryFailure);
}

private static async Task AddDisabledMcpAsync(OpenCodeClient client, string name, CancellationToken token) =>
_ = await client.Experimental.AddMcpServerAsync(name, new ExperimentalMcpAddRequest
{
Config = new McpRemoteConfig
{
Disabled = true,
OAuth = McpRemoteConfigOAuth.FromBoolean(false),
Url = "https://sdk-coverage.invalid/mcp",
},
}, cancellationToken: token);

private static async Task<McpStatusChanged> WaitForMcpChangeAsync(SessionEventProbe probe, string name, CancellationToken token)
{
using var barrier = SessionEventProbe.Barrier(token);
return await probe.WaitForAsync<McpStatusChanged>(item => item.Data.Server == name, "owned disabled MCP entry", barrier.Token);
}

private static async Task AssertDisabledMcpAsync(OpenCodeClient client, string name, CancellationToken token)
{
var response = await client.McpServers.ListMcpServersAsync(cancellationToken: token);
var entries = response.McpServers.Where(item => item.Name == name).ToList();
await Assert.That(entries).Count().IsEqualTo(1);
await Assert.That(entries[0].Status).IsTypeOf<McpStatusDisabled>();
}

private static async Task RemoveOwnedMcpAsync(OpenCodeClient client, string name, CancellationToken token)
{
var response = await client.Experimental.RemoveMcpServerAsync(name, requestOptions: OpenCodeRequestOptions.NoThrow, cancellationToken: token);
if (response.Status == 204 || (response is { Status: 404, Error: McpServerNotFoundError missing } && missing.Server == name))
{
return;
}

throw new InvalidOperationException("Owned MCP removal failed: " + response.Error?.GetType().Name);
}
}
35 changes: 35 additions & 0 deletions tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Globalization;
using OpenCode.Sdk.Models;
using OpenCode.Sdk.Tests.Harness;
using OpenCode.Sdk.Tests.Harness.Ownership;

namespace OpenCode.Sdk.Tests.Projects;

Expand All @@ -9,6 +11,39 @@ public sealed class ProjectsClientLiveTests(SimulatedDriveServerFixture server)
{
private const string OwnerFile = "sdk-live-project-owner.txt";

[Test]
[Timeout(60_000)]
public async Task UpdateProjectAsync_Should_Persist_The_Display_Name_When_A_Project_Exists(
CancellationToken cancellationToken)
{
using var workspace = server.CreateWorkspace();
using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path });
var location = await client.GetLocationAsync(cancellationToken: cancellationToken);
var projectId = location.ResolvedLocation.Project.Id;
var before = await client.Projects.ListProjectsAsync(cancellationToken: cancellationToken);
var original = before.Projects.Single(project => project.Id == projectId);
var cleanup = new OwnedCleanup(TimeSpan.FromSeconds(15));
cleanup.Own("restore project display name", async token =>
_ = await client.Projects.UpdateProjectAsync(projectId, new ProjectUpdateRequest { Name = original.Name ?? string.Empty }, cancellationToken: token));
Exception? primaryFailure = null;
try
{
var name = "sdk-project-" + Guid.NewGuid().ToString("N");
await Assert.That(original.Name).IsNotEqualTo(name);

_ = await client.Projects.UpdateProjectAsync(projectId, new ProjectUpdateRequest { Name = name }, cancellationToken: cancellationToken);

var after = await client.Projects.ListProjectsAsync(cancellationToken: cancellationToken);
await Assert.That(after.Projects.Single(project => project.Id == projectId).Name).IsEqualTo(name);
}
catch (Exception exception)
{
primaryFailure = exception;
}

await cleanup.CompleteAsync(primaryFailure);
}

[Test]
[Timeout(60_000)]
public async Task GetLocationAsync_And_ListProjectsAsync_Should_Report_The_Workspace_Project(
Expand Down
62 changes: 62 additions & 0 deletions tests/OpenCode.Sdk.Tests/Shells/ShellClientLiveTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Text;
using OpenCode.Sdk.Models;
using OpenCode.Sdk.Tests.Harness;
using OpenCode.Sdk.Tests.Harness.Ownership;
using OpenCode.Sdk.Tests.Support;

namespace OpenCode.Sdk.Tests.Shells;

[ClassDataSource<SimulatedDriveServerFixture>(Shared = SharedType.PerTestSession)]
[NotInParallel(ParallelConstraintKeys.ServerProcess)]
public sealed class ShellClientLiveTests(SimulatedDriveServerFixture server)
{
[Test]
[Timeout(180_000)]
public async Task GetOutputAsync_Should_Page_The_Captured_Bytes_When_A_Command_Has_Exited(CancellationToken cancellationToken)
{
using var workspace = server.CreateWorkspace();
using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path });
var cleanup = new OwnedCleanup(TimeSpan.FromSeconds(15));
var probe = ConnectOutputProbe(client, cleanup, cancellationToken);
Exception? primaryFailure = null;
try
{
await probe.WaitForConnectedAsync(cancellationToken);
var marker = "sdk-output-page-" + Guid.NewGuid().ToString("N");
var created = await client.Shells.CreateShellAsync(new ShellCreateRequest { Command = "echo " + marker, Cwd = workspace.Path }, cancellationToken: cancellationToken);
var shell = client.Shells.GetShellClient(created.Shell.Id);
cleanup.Own("paged shell removal", async token => _ = await shell.RemoveAsync(cancellationToken: token));
using var barrier = SessionEventProbe.Barrier(cancellationToken);
_ = await probe.WaitForAsync<ShellExited>(item => item.Data.Id == created.Shell.Id, "paged shell exit", barrier.Token);

var first = await shell.GetOutputAsync(new ShellOutputRequest { Cursor = 0, Limit = 1 }, cancellationToken: cancellationToken);
await Assert.That(first.Output.Output).IsEqualTo(marker[..1]);
await Assert.That(first.Output.Cursor).IsEqualTo(1);
await Assert.That(first.Output.Truncated).IsFalse();
var rest = await shell.GetOutputAsync(new ShellOutputRequest { Cursor = first.Output.Cursor }, cancellationToken: cancellationToken);
var combined = first.Output.Output + rest.Output.Output;
await Assert.That(combined).IsEqualTo(marker + "\n").Or.IsEqualTo(marker + "\r\n");
await Assert.That(rest.Output.Cursor).IsEqualTo(Encoding.UTF8.GetByteCount(combined));
await Assert.That(rest.Output.Cursor).IsEqualTo(rest.Output.Size);
await Assert.That(rest.Output.Truncated).IsFalse();
var terminal = await shell.GetOutputAsync(new ShellOutputRequest { Cursor = rest.Output.Cursor }, cancellationToken: cancellationToken);
await Assert.That(terminal.Output.Output).IsEmpty();
await Assert.That(terminal.Output.Cursor).IsEqualTo(rest.Output.Size);
}
catch (Exception exception)
{
primaryFailure = exception;
}

await cleanup.CompleteAsync(primaryFailure);
}

private static SessionEventProbe ConnectOutputProbe(OpenCodeClient client, OwnedCleanup cleanup, CancellationToken token)
{
var reader = new OwnedEventReader(TimeSpan.FromSeconds(120), TimeSpan.FromSeconds(15), token);
cleanup.Own("output event reader", TimeSpan.FromSeconds(75), _ => reader.CompleteAsync(null));
var probe = new SessionEventProbe(reader);
probe.Start(client.Events.SubscribeAsync(cancellationToken: reader.Token));
return probe;
}
}
60 changes: 60 additions & 0 deletions tests/OpenCode.Sdk.Tests/Shells/ShellsClientLiveTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using OpenCode.Sdk.Models;
using OpenCode.Sdk.Tests.Harness;
using OpenCode.Sdk.Tests.Harness.Ownership;
using OpenCode.Sdk.Tests.Support;

namespace OpenCode.Sdk.Tests.Shells;

[ClassDataSource<SimulatedDriveServerFixture>(Shared = SharedType.PerTestSession)]
[NotInParallel(ParallelConstraintKeys.ServerProcess)]
public sealed class ShellsClientLiveTests(SimulatedDriveServerFixture server)
{
[Test]
[Timeout(180_000)]
public async Task CreateShellAsync_Should_Start_The_Command_When_The_Workspace_Is_Owned(CancellationToken cancellationToken)
{
using var workspace = server.CreateWorkspace();
using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path });
var cleanup = new OwnedCleanup(TimeSpan.FromSeconds(15));
Exception? primaryFailure = null;
try
{
var probe = await StartCreationEventsAsync(client, cleanup, cancellationToken);
var marker = "sdk-create-shell-" + Guid.NewGuid().ToString("N");
var command = "echo " + marker;

var created = await client.Shells.CreateShellAsync(new ShellCreateRequest { Command = command, Cwd = workspace.Path }, cancellationToken: cancellationToken);
var shell = client.Shells.GetShellClient(created.Shell.Id);
cleanup.Own("created shell removal", async token => _ = await shell.RemoveAsync(cancellationToken: token));
// A fabricated admission must fail on finite state, not a missing exit event.
var stored = await shell.GetAsync(requestOptions: OpenCodeRequestOptions.NoThrow, cancellationToken: cancellationToken);
await Assert.That(stored.Status).IsEqualTo(200);
await Assert.That(stored.Shell.Command).IsEqualTo(command);
await Assert.That(stored.Shell.Cwd.Replace('\\', '/')).IsEqualTo(workspace.Path.Replace('\\', '/'));
using var barrier = SessionEventProbe.Barrier(cancellationToken);
_ = await probe.WaitForAsync<ShellExited>(item => item.Data.Id == created.Shell.Id, "created shell exit", barrier.Token);
var exited = await shell.GetAsync(cancellationToken: cancellationToken);
await Assert.That(exited.Shell.Status).IsEqualTo(ShellInfoStatus.Exited);
await Assert.That(exited.Shell.Exit).IsEqualTo(0);
var output = await shell.GetOutputAsync(cancellationToken: cancellationToken);
await Assert.That(output.Output.Output.Trim()).IsEqualTo(marker);
}
catch (Exception exception)
{
primaryFailure = exception;
}

await cleanup.CompleteAsync(primaryFailure);
}

private static async Task<SessionEventProbe> StartCreationEventsAsync(OpenCodeClient client, OwnedCleanup cleanup, CancellationToken token)
{
var stepTimeout = TimeSpan.FromSeconds(15);
var reader = new OwnedEventReader(TimeSpan.FromSeconds(120), stepTimeout, token);
cleanup.Own("shell creation events", TimeSpan.FromTicks(stepTimeout.Ticks * 5), _ => reader.CompleteAsync(null));
var probe = new SessionEventProbe(reader);
probe.Start(client.Events.SubscribeAsync(cancellationToken: reader.Token));
await probe.WaitForConnectedAsync(token);
return probe;
}
}
Loading
Loading