diff --git a/tests/OpenCode.Sdk.Tests/Experimental/ExperimentalClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Experimental/ExperimentalClientLiveTests.cs new file mode 100644 index 00000000..aeaf00f1 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Experimental/ExperimentalClientLiveTests.cs @@ -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(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(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 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 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 WaitForMcpChangeAsync(SessionEventProbe probe, string name, CancellationToken token) + { + using var barrier = SessionEventProbe.Barrier(token); + return await probe.WaitForAsync(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(); + } + + 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); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs index 25d105dc..959c57b2 100644 --- a/tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs @@ -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; @@ -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( diff --git a/tests/OpenCode.Sdk.Tests/Shells/ShellClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Shells/ShellClientLiveTests.cs new file mode 100644 index 00000000..cd06b530 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Shells/ShellClientLiveTests.cs @@ -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(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(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; + } +} diff --git a/tests/OpenCode.Sdk.Tests/Shells/ShellsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Shells/ShellsClientLiveTests.cs new file mode 100644 index 00000000..4d146ea4 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Shells/ShellsClientLiveTests.cs @@ -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(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(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 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; + } +} diff --git a/tests/OpenCode.Sdk.Tests/Vcs/VcsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Vcs/VcsClientLiveTests.cs index 454feb08..86df298f 100644 --- a/tests/OpenCode.Sdk.Tests/Vcs/VcsClientLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Vcs/VcsClientLiveTests.cs @@ -10,6 +10,25 @@ namespace OpenCode.Sdk.Tests.Vcs; [NotInParallel(ParallelConstraintKeys.ServerProcess)] public sealed class VcsClientLiveTests(SimulatedDriveServerFixture server) { + [Test] + [Timeout(60_000)] + public async Task InitializeRepositoryAsync_Should_Refuse_Reinitialization_When_The_First_Call_Initialized_Git( + 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 request = new VcsInitRequest { Provider = "git" }; + + var initialized = await client.Vcs.InitializeRepositoryAsync(request, cancellationToken: cancellationToken); + await Assert.That(initialized.Status).IsEqualTo(204); + var repeated = await client.Vcs.InitializeRepositoryAsync(request, OpenCodeRequestOptions.NoThrow, cancellationToken); + + await Assert.That(repeated.Status).IsEqualTo(409); + await Assert.That(repeated.Error).IsTypeOf(); + await Assert.That(((ConflictError)repeated.Error!).Resource).IsEqualTo(location.ResolvedLocation.Project.Directory); + } + [Test] [Timeout(60_000)] public async Task GetVcsAsync_ListBranchesAsync_And_GetBaseAsync_Should_Report_The_Initialized_Main_Branch(