diff --git a/tests/OpenCode.Sdk.Tests/Credentials/CredentialsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Credentials/CredentialsClientLiveTests.cs new file mode 100644 index 00000000..204ac696 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Credentials/CredentialsClientLiveTests.cs @@ -0,0 +1,153 @@ +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Harness; +using OpenCode.Sdk.Tests.Harness.Ownership; + +namespace OpenCode.Sdk.Tests.Credentials; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class CredentialsClientLiveTests(SimulatedDriveServerFixture server) +{ + private const string InitialLabel = "Owned coverage credential"; + private const string ReplacementLabel = "Renamed owned credential"; + private readonly string _integrationId = "sdk-credential-" + Guid.NewGuid().ToString("N"); + + [Test] + [Timeout(60_000)] + public async Task RemoveCredentialAsync_Should_Remove_The_Owned_Row_When_It_Exists(CancellationToken cancellationToken) + { + await WithCredentialsAsync(async (client, cleanup) => + { + var id = await CreateOwnedAsync(client, cleanup, InitialLabel, cancellationToken); + var before = await ListOwnedAsync(client, [id], cancellationToken); + await Assert.That(before.Select(entry => entry.Id)).Contains(id); + + _ = await client.Credentials.RemoveCredentialAsync(id, cancellationToken: cancellationToken); + + var after = await ListOwnedAsync(client, [id], cancellationToken); + await Assert.That(after.Select(entry => entry.Id)).DoesNotContain(id); + }); + } + + [Test] + [Timeout(60_000)] + public async Task CreateCredentialAsync_Should_Persist_The_Row_When_The_Id_Is_Unused(CancellationToken cancellationToken) + { + await WithCredentialsAsync(async (client, cleanup) => + { + var (request, id) = OwnCredential(client, cleanup, InitialLabel); + + _ = await client.Credentials.CreateCredentialAsync(request, cancellationToken: cancellationToken); + + _ = await AssertOwnedRowAsync(client, id, InitialLabel, cancellationToken); + }); + } + + [Test] + [Timeout(60_000)] + public async Task ActivateCredentialAsync_Should_Select_The_Older_Row_When_A_Newer_Row_Is_Active(CancellationToken cancellationToken) + { + await WithCredentialsAsync(async (client, cleanup) => + { + var olderId = await CreateOwnedAsync(client, cleanup, InitialLabel, cancellationToken); + var newerId = await CreateOwnedAsync(client, cleanup, ReplacementLabel, cancellationToken); + var before = await ListOwnedAsync(client, [olderId, newerId], cancellationToken); + await Assert.That(before.Single(entry => entry.Id == olderId).Active).IsFalse(); + await Assert.That(before.Single(entry => entry.Id == newerId).Active).IsTrue(); + + _ = await client.Credentials.ActivateCredentialAsync(olderId, cancellationToken: cancellationToken); + + var after = await ListOwnedAsync(client, [olderId, newerId], cancellationToken); + await Assert.That(after.Single(entry => entry.Id == olderId).Active).IsTrue(); + await Assert.That(after.Single(entry => entry.Id == newerId).Active).IsFalse(); + }); + } + + [Test] + [Timeout(60_000)] + public async Task ListCredentialsAsync_Should_Return_The_Owned_Metadata_When_A_Row_Was_Created(CancellationToken cancellationToken) + { + await WithCredentialsAsync(async (client, cleanup) => + { + var id = await CreateOwnedAsync(client, cleanup, InitialLabel, cancellationToken); + + var entry = await AssertOwnedRowAsync(client, id, InitialLabel, cancellationToken); + await Assert.That(entry.Active).IsTrue(); + }); + } + + [Test] + [Timeout(60_000)] + public async Task UpdateCredentialAsync_Should_Replace_The_Label_When_A_Row_Exists(CancellationToken cancellationToken) + { + await WithCredentialsAsync(async (client, cleanup) => + { + var id = await CreateOwnedAsync(client, cleanup, InitialLabel, cancellationToken); + + _ = await client.Credentials.UpdateCredentialAsync(id, + new CredentialUpdateRequest { Label = ReplacementLabel }, cancellationToken: cancellationToken); + + var owned = await ListOwnedAsync(client, [id], cancellationToken); + await Assert.That(owned.Single().Label).IsEqualTo(ReplacementLabel); + }); + } + + private async Task WithCredentialsAsync(Func scenario) + { + using var workspace = server.CreateWorkspace(); + using var client = CreateWorkspaceClient(workspace); + var cleanup = new OwnedCleanup(TimeSpan.FromSeconds(15)); + Exception? failure = null; + try + { + await scenario(client, cleanup); + } + catch (Exception exception) + { + failure = exception; + } + + await cleanup.CompleteAsync(failure); + } + + private OpenCodeClient CreateWorkspaceClient(TestWorkspace workspace) => + server.CreateClient(new LocationSelector { Directory = workspace.Path }); + + private (CredentialCreateRequest Request, string Id) OwnCredential(OpenCodeClient client, OwnedCleanup cleanup, string label) + { + var id = "cred_" + Guid.NewGuid().ToString("N"); + cleanup.Own("owned credential removal", async token => + _ = await client.Credentials.RemoveCredentialAsync(id, cancellationToken: token)); + return (new CredentialCreateRequest + { + Id = id, + IntegrationId = _integrationId, + Label = label, + Value = new CredentialKey { Key = "inert-sdk-coverage-sentinel" }, + }, id); + } + + private async Task CreateOwnedAsync(OpenCodeClient client, OwnedCleanup cleanup, string label, CancellationToken token) + { + var (request, id) = OwnCredential(client, cleanup, label); + _ = await client.Credentials.CreateCredentialAsync(request, cancellationToken: token); + return id; + } + + private async Task AssertOwnedRowAsync(OpenCodeClient client, string id, string label, CancellationToken token) + { + var owned = await ListOwnedAsync(client, [id], token); + await Assert.That(owned.Select(entry => entry.Id)).Contains(id); + var entry = owned.Single(); + await Assert.That(entry.IntegrationId).IsEqualTo(_integrationId); + await Assert.That(entry.Label).IsEqualTo(label); + return entry; + } + + private static async Task> ListOwnedAsync(OpenCodeClient client, IEnumerable ids, CancellationToken token) + { + var response = await client.Credentials.ListCredentialsAsync(cancellationToken: token); + // Filter before inspecting metadata; never inspect or assert a credential's value. + return [.. response.Credentials.Where(entry => ids.Contains(entry.Id, StringComparer.Ordinal))]; + } +} diff --git a/tests/OpenCode.Sdk.Tests/Experimental/ExperimentalClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Experimental/ExperimentalClientLiveTests.cs index aeaf00f1..8cd5fc06 100644 --- a/tests/OpenCode.Sdk.Tests/Experimental/ExperimentalClientLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Experimental/ExperimentalClientLiveTests.cs @@ -24,7 +24,7 @@ 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); + await DisabledMcpServer.AddAsync(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); @@ -40,7 +40,7 @@ public async Task RemoveMcpServerAsync_Should_Remove_The_Entry_When_It_Exists(Ca { await WithMcpEntryAsync(async (client, name, probe) => { - await AddDisabledMcpAsync(client, name, cancellationToken); + await DisabledMcpServer.AddAsync(client, name, cancellationToken); var added = await WaitForMcpChangeAsync(probe, name, cancellationToken); await AssertDisabledMcpAsync(client, name, cancellationToken); @@ -138,7 +138,7 @@ private async Task WithMcpEntryAsync(Func await RemoveOwnedMcpAsync(client, name, cancellation)); + cleanup.Own("runtime MCP removal", async cancellation => await DisabledMcpServer.RemoveOwnedAsync(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); @@ -157,17 +157,6 @@ private async Task WithMcpEntryAsync(Func - _ = 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); @@ -181,15 +170,4 @@ private static async Task AssertDisabledMcpAsync(OpenCodeClient client, string n 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/Integrations/IntegrationClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Integrations/IntegrationClientLiveTests.cs new file mode 100644 index 00000000..bcff3268 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Integrations/IntegrationClientLiveTests.cs @@ -0,0 +1,81 @@ +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Harness; +using OpenCode.Sdk.Tests.Harness.Ownership; + +namespace OpenCode.Sdk.Tests.Integrations; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class IntegrationClientLiveTests(SimulatedDriveServerFixture server) +{ + [Test] + [Timeout(60_000)] + public async Task ConnectWithKeyAsync_Should_Store_A_Connection_When_The_Seeded_Key_Method_Is_Used(CancellationToken cancellationToken) + { + await WithSeededIntegrationAsync(async (client, integration, cleanup) => + { + var label = "sdk-key-" + Guid.NewGuid().ToString("N"); + cleanup.Own("owned connection credential removal", async token => + { + var owned = await ListOwnedIdsAsync(client, label, token); + foreach (var id in owned) + { + _ = await client.Credentials.RemoveCredentialAsync(id, cancellationToken: token); + } + }); + + _ = await integration.ConnectWithKeyAsync(new IntegrationConnectKeyRequest + { + Key = "inert-local-connection-sentinel", + Label = label, + }, cancellationToken: cancellationToken); + + var createdIds = await ListOwnedIdsAsync(client, label, cancellationToken); + await Assert.That(createdIds.Count).IsEqualTo(1); + }, cancellationToken); + } + + [Test] + [Timeout(60_000)] + public async Task GetAsync_Should_Return_The_Seeded_Integration_When_Its_Id_Is_Requested(CancellationToken cancellationToken) + { + await WithSeededIntegrationAsync(async (_, integration, _) => + { + var response = await integration.GetAsync(cancellationToken: cancellationToken); + + await Assert.That(response.Integration.Id).IsEqualTo(SimulationConfigSeed.ProviderId); + await Assert.That(response.Integration.Name).IsEqualTo("Simulated"); + await Assert.That(response.Integration.Methods).Contains(method => method is IntegrationKeyMethod); + }, cancellationToken); + } + + private async Task WithSeededIntegrationAsync(Func scenario, CancellationToken token) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var cleanup = new OwnedCleanup(TimeSpan.FromSeconds(15)); + Exception? failure = null; + try + { + // The list handler awaits plugin activation; Get deliberately does not. + var catalog = await client.Integrations.ListIntegrationsAsync(cancellationToken: token); + var seeded = catalog.Integrations.Single(item => item.Id == SimulationConfigSeed.ProviderId); + await Assert.That(seeded.Methods).Contains(method => method is IntegrationKeyMethod); + await scenario(client, client.Integrations.GetIntegrationClient(SimulationConfigSeed.ProviderId), cleanup); + } + catch (Exception exception) + { + failure = exception; + } + + await cleanup.CompleteAsync(failure); + } + + private static async Task> ListOwnedIdsAsync(OpenCodeClient client, string label, CancellationToken token) + { + var response = await client.Credentials.ListCredentialsAsync(cancellationToken: token); + return [.. response.Credentials + .Where(entry => entry.Label == label && entry.IntegrationId == SimulationConfigSeed.ProviderId) + .Select(entry => entry.Id)]; + } +} diff --git a/tests/OpenCode.Sdk.Tests/Integrations/IntegrationsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Integrations/IntegrationsClientLiveTests.cs new file mode 100644 index 00000000..973c9651 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Integrations/IntegrationsClientLiveTests.cs @@ -0,0 +1,24 @@ +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Harness; + +namespace OpenCode.Sdk.Tests.Integrations; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class IntegrationsClientLiveTests(SimulatedDriveServerFixture server) +{ + [Test] + [Timeout(60_000)] + public async Task ListIntegrationsAsync_Should_Include_The_Seeded_Provider_When_Plugins_Are_Activated(CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + + var response = await client.Integrations.ListIntegrationsAsync(cancellationToken: cancellationToken); + + var seeded = response.Integrations.Where(item => item.Id == SimulationConfigSeed.ProviderId).ToList(); + await Assert.That(seeded.Select(item => item.Id)).Contains(SimulationConfigSeed.ProviderId); + await Assert.That(seeded.Single().Name).IsEqualTo("Simulated"); + await Assert.That(seeded.Single().Methods).Contains(method => method is IntegrationKeyMethod); + } +} diff --git a/tests/OpenCode.Sdk.Tests/McpServers/McpServersClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/McpServers/McpServersClientLiveTests.cs new file mode 100644 index 00000000..64bd4649 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/McpServers/McpServersClientLiveTests.cs @@ -0,0 +1,50 @@ +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Harness; +using OpenCode.Sdk.Tests.Harness.Ownership; +using OpenCode.Sdk.Tests.Support; + +namespace OpenCode.Sdk.Tests.McpServers; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class McpServersClientLiveTests(SimulatedDriveServerFixture server) +{ + [Test] + [Timeout(180_000)] + public async Task ListMcpServersAsync_Should_Report_The_Disabled_Entry_When_It_Was_Added_At_Runtime(CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var name = "sdk-catalog-" + Guid.NewGuid().ToString("N"); + var cleanup = new OwnedCleanup(TimeSpan.FromSeconds(15)); + cleanup.Own("owned catalog MCP removal", async token => await DisabledMcpServer.RemoveOwnedAsync(client, name, token)); + var reader = new OwnedEventReader(TimeSpan.FromSeconds(120), TimeSpan.FromSeconds(15), cancellationToken); + cleanup.Own("catalog event reader", TimeSpan.FromSeconds(75), _ => reader.CompleteAsync(null)); + var probe = new SessionEventProbe(reader); + Exception? failure = null; + try + { + probe.Start(client.Events.SubscribeAsync(cancellationToken: reader.Token)); + await probe.WaitForConnectedAsync(cancellationToken); + await DisabledMcpServer.AddAsync(client, name, cancellationToken); + + // A finite read kills an empty-list mutant before any event wait can stall. + var response = await client.McpServers.ListMcpServersAsync(cancellationToken: cancellationToken); + var owned = response.McpServers.Where(entry => entry.Name == name).ToList(); + await Assert.That(owned.Select(entry => entry.Name)).Contains(name); + using var barrier = SessionEventProbe.Barrier(cancellationToken); + var changed = await probe.WaitForAsync(entry => entry.Data.Server == name, + "owned catalog MCP registration", barrier.Token); + await Assert.That(changed.Data.Server).IsEqualTo(name); + + var reconciled = await client.McpServers.ListMcpServersAsync(cancellationToken: cancellationToken); + await Assert.That(reconciled.McpServers.Single(entry => entry.Name == name).Status).IsTypeOf(); + } + catch (Exception exception) + { + failure = exception; + } + + await cleanup.CompleteAsync(failure); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/DisabledMcpServer.cs b/tests/OpenCode.Sdk.Tests/Support/DisabledMcpServer.cs new file mode 100644 index 00000000..aa593c73 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/DisabledMcpServer.cs @@ -0,0 +1,30 @@ +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Tests.Support; + +/// A runtime MCP entry live tests own: registered disabled with OAuth off at an inert .invalid URL, so the +/// server never connects, and removed tolerating only the exact already-missing refusal. +internal static class DisabledMcpServer +{ + public static async Task AddAsync(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); + + public static async Task RemoveOwnedAsync(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); + } +}