Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1775,6 +1775,7 @@ internal record PermissionRequestResponseV2(
[JsonSerializable(typeof(GetSessionMetadataResponse))]
[JsonSerializable(typeof(ModelCapabilitiesOverride))]
[JsonSerializable(typeof(PermissionRequestResult))]
[JsonSerializable(typeof(PermissionRequestResultKind))]
[JsonSerializable(typeof(PermissionRequestResponseV2))]
[JsonSerializable(typeof(ProviderConfig))]
[JsonSerializable(typeof(ResumeSessionRequest))]
Expand Down
5 changes: 5 additions & 0 deletions dotnet/src/Generated/Rpc.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2787,6 +2787,7 @@ public class SystemMessageTransformRpcResponse
[JsonSerializable(typeof(ModelSupports))]
[JsonSerializable(typeof(ModelVisionLimits))]
[JsonSerializable(typeof(PermissionRequestResult))]
[JsonSerializable(typeof(PermissionRequestResultKind))]
[JsonSerializable(typeof(PingRequest))]
[JsonSerializable(typeof(PingResponse))]
[JsonSerializable(typeof(ProviderConfig))]
Expand Down
15 changes: 15 additions & 0 deletions dotnet/test/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

using Xunit;

// Each E2E test class fixture spins up its own Copilot CLI subprocess plus a CapiProxy
// (replaying HTTP proxy) Node.js subprocess. With ~25 test classes, running them in parallel
// would launch ~50 long-lived Node.js processes simultaneously and exhaust both file
// descriptors and memory on developer machines and CI runners (especially Windows). Tests
// within a class already run serially via xUnit's IClassFixture contract; this attribute
// extends that to cross-class execution. Re-enable parallelization only after either
// (a) sharing a single CLI subprocess across classes, or (b) gating concurrency with a
// semaphore that limits concurrent fixtures to a small number (e.g. 2-3).
[assembly: CollectionBehavior(DisableTestParallelization = true)]
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
using Xunit;
using Xunit.Abstractions;

namespace GitHub.Copilot.SDK.Test;
namespace GitHub.Copilot.SDK.Test.E2E;

public class AskUserTests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "ask_user", output)
public class AskUserE2ETests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "ask_user", output)
{
[Fact]
public async Task Should_Invoke_User_Input_Handler_When_Model_Uses_Ask_User_Tool()
Expand Down
137 changes: 137 additions & 0 deletions dotnet/test/E2E/BuiltinToolsE2ETests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

using GitHub.Copilot.SDK.Test.Harness;
using Xunit;
using Xunit.Abstractions;

namespace GitHub.Copilot.SDK.Test.E2E;

/// <summary>
/// Smoke coverage for the Copilot CLI built-in tools (bash, view, edit, create_file,
/// grep, glob). Each test asks the model to use one tool and then verifies the model's
/// final response reflects the tool's result. Mirrors
/// <c>nodejs/test/e2e/builtin_tools.e2e.test.ts</c>.
/// </summary>
public class BuiltinToolsE2ETests(E2ETestFixture fixture, ITestOutputHelper output)
: E2ETestBase(fixture, "builtin_tools", output)
{
[Fact]
public async Task Should_Capture_Exit_Code_In_Output()
{
var session = await CreateSessionAsync();
var msg = await session.SendAndWaitAsync(new MessageOptions
{
Prompt = "Run 'echo hello && echo world'. Tell me the exact output.",
});
var content = msg?.Data.Content ?? string.Empty;
Assert.Contains("hello", content);
Assert.Contains("world", content);
}

[Fact]
public async Task Should_Capture_Stderr_Output()
{
// The Copilot CLI runs commands through a shell tool that resolves to bash on
// Linux/macOS and PowerShell on Windows. The TS prompt only works on bash, so
// skip this test on Windows to mirror the TS `it.skipIf(process.platform === "win32")`.
if (OperatingSystem.IsWindows())
{
return;
}

var session = await CreateSessionAsync();
var msg = await session.SendAndWaitAsync(new MessageOptions
{
Prompt = "Run 'echo error_msg >&2; echo ok' and tell me what stderr said. Reply with just the stderr content.",
});
Assert.Contains("error_msg", msg?.Data.Content ?? string.Empty);
}

[Fact]
public async Task Should_Read_File_With_Line_Range()
{
await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "lines.txt"), "line1\nline2\nline3\nline4\nline5\n");
var session = await CreateSessionAsync();
var msg = await session.SendAndWaitAsync(new MessageOptions
{
Prompt = "Read lines 2 through 4 of the file 'lines.txt' in this directory. Tell me what those lines contain.",
});
var content = msg?.Data.Content ?? string.Empty;
Assert.Contains("line2", content);
Assert.Contains("line4", content);
}

[Fact]
public async Task Should_Handle_Nonexistent_File_Gracefully()
{
var session = await CreateSessionAsync();
var msg = await session.SendAndWaitAsync(new MessageOptions
{
Prompt = "Try to read the file 'does_not_exist.txt'. If it doesn't exist, say 'FILE_NOT_FOUND'.",
});
var content = (msg?.Data.Content ?? string.Empty).ToUpperInvariant();
// Match any of the common phrasings for a missing-file response.
Assert.True(
content.Contains("NOT FOUND")
|| content.Contains("NOT EXIST")
|| content.Contains("NO SUCH")
|| content.Contains("FILE_NOT_FOUND")
|| content.Contains("DOES NOT EXIST")
|| content.Contains("ERROR"),
$"Expected a 'not found'-style response, got: {msg?.Data.Content}");
}

[Fact]
public async Task Should_Edit_A_File_Successfully()
{
await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "edit_me.txt"), "Hello World\nGoodbye World\n");
var session = await CreateSessionAsync();
var msg = await session.SendAndWaitAsync(new MessageOptions
{
Prompt = "Edit the file 'edit_me.txt': replace 'Hello World' with 'Hi Universe'. Then read it back and tell me its contents.",
});
Assert.Contains("Hi Universe", msg?.Data.Content ?? string.Empty);
}

[Fact]
public async Task Should_Create_A_New_File()
{
var session = await CreateSessionAsync();
var msg = await session.SendAndWaitAsync(new MessageOptions
{
Prompt = "Create a file called 'new_file.txt' with the content 'Created by test'. Then read it back to confirm.",
});
Assert.Contains("Created by test", msg?.Data.Content ?? string.Empty);
}

[Fact]
public async Task Should_Search_For_Patterns_In_Files()
{
await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "data.txt"), "apple\nbanana\napricot\ncherry\n");
var session = await CreateSessionAsync();
var msg = await session.SendAndWaitAsync(new MessageOptions
{
Prompt = "Search for lines starting with 'ap' in the file 'data.txt'. Tell me which lines matched.",
});
var content = msg?.Data.Content ?? string.Empty;
Assert.Contains("apple", content);
Assert.Contains("apricot", content);
}

[Fact]
public async Task Should_Find_Files_By_Pattern()
{
Directory.CreateDirectory(Path.Join(Ctx.WorkDir, "src"));
await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "src", "index.ts"), "export const index = 1;");
await File.WriteAllTextAsync(Path.Join(Ctx.WorkDir, "README.md"), "# Readme");

var session = await CreateSessionAsync();
var msg = await session.SendAndWaitAsync(new MessageOptions
{
Prompt = "Find all .ts files in this directory (recursively). List the filenames you found.",
});
Assert.Contains("index.ts", msg?.Data.Content ?? string.Empty);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@

using Xunit;

namespace GitHub.Copilot.SDK.Test;
namespace GitHub.Copilot.SDK.Test.E2E;

// These tests bypass E2ETestBase because they are about how the CLI subprocess is started
// Other test classes should instead inherit from E2ETestBase
public class ClientTests
public class ClientE2ETests
{
[Fact]
public async Task Should_Start_And_Connect_To_Server_Using_Stdio()
Expand Down Expand Up @@ -148,93 +148,6 @@ public async Task Should_List_Models_When_Authenticated()
}
}

[Fact]
public void Should_Accept_GitHubToken_Option()
{
var options = new CopilotClientOptions
{
GitHubToken = "gho_test_token"
};

Assert.Equal("gho_test_token", options.GitHubToken);
}

[Fact]
public void Should_Default_UseLoggedInUser_To_Null()
{
var options = new CopilotClientOptions();

Assert.Null(options.UseLoggedInUser);
}

[Fact]
public void Should_Allow_Explicit_UseLoggedInUser_False()
{
var options = new CopilotClientOptions
{
UseLoggedInUser = false
};

Assert.False(options.UseLoggedInUser);
}

[Fact]
public void Should_Allow_Explicit_UseLoggedInUser_True_With_GitHubToken()
{
var options = new CopilotClientOptions
{
GitHubToken = "gho_test_token",
UseLoggedInUser = true
};

Assert.True(options.UseLoggedInUser);
}

[Fact]
public void Should_Throw_When_GitHubToken_Used_With_CliUrl()
{
Assert.Throws<ArgumentException>(() =>
{
_ = new CopilotClient(new CopilotClientOptions
{
CliUrl = "localhost:8080",
GitHubToken = "gho_test_token"
});
});
}

[Fact]
public void Should_Throw_When_UseLoggedInUser_Used_With_CliUrl()
{
Assert.Throws<ArgumentException>(() =>
{
_ = new CopilotClient(new CopilotClientOptions
{
CliUrl = "localhost:8080",
UseLoggedInUser = false
});
});
}

[Fact]
public void Should_Default_SessionIdleTimeoutSeconds_To_Null()
{
var options = new CopilotClientOptions();

Assert.Null(options.SessionIdleTimeoutSeconds);
}

[Fact]
public void Should_Accept_SessionIdleTimeoutSeconds_Option()
{
var options = new CopilotClientOptions
{
SessionIdleTimeoutSeconds = 600
};

Assert.Equal(600, options.SessionIdleTimeoutSeconds);
}

[Fact]
public async Task Should_Not_Throw_When_Disposing_Session_After_Stopping_Client()
{
Expand Down
83 changes: 83 additions & 0 deletions dotnet/test/E2E/ClientLifecycleE2ETests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

using Xunit;
using Xunit.Abstractions;

namespace GitHub.Copilot.SDK.Test.E2E;

public class ClientLifecycleE2ETests(E2ETestFixture fixture, ITestOutputHelper output)
: E2ETestBase(fixture, "client_lifecycle", output)
{
[Fact]
public async Task Should_Receive_Session_Created_Lifecycle_Event()
{
var created = new TaskCompletionSource<SessionLifecycleEvent>(TaskCreationOptions.RunContinuationsAsynchronously);
using var subscription = Client.On(evt =>
{
if (evt.Type == SessionLifecycleEventTypes.Created)
{
created.TrySetResult(evt);
}
});

var session = await CreateSessionAsync();
var evt = await created.Task.WaitAsync(TimeSpan.FromSeconds(10));

Assert.Equal(SessionLifecycleEventTypes.Created, evt.Type);
Assert.Equal(session.SessionId, evt.SessionId);
}

[Fact]
public async Task Should_Filter_Session_Lifecycle_Events_By_Type()
{
var created = new TaskCompletionSource<SessionLifecycleEvent>(TaskCreationOptions.RunContinuationsAsynchronously);
using var subscription = Client.On(SessionLifecycleEventTypes.Created, evt => created.TrySetResult(evt));

var session = await CreateSessionAsync();
var evt = await created.Task.WaitAsync(TimeSpan.FromSeconds(10));

Assert.Equal(SessionLifecycleEventTypes.Created, evt.Type);
Assert.Equal(session.SessionId, evt.SessionId);
}

[Fact]
public async Task Disposing_Lifecycle_Subscription_Stops_Receiving_Events()
{
var count = 0;
var created = new TaskCompletionSource<SessionLifecycleEvent>(TaskCreationOptions.RunContinuationsAsynchronously);
var subscription = Client.On(_ => Interlocked.Increment(ref count));
subscription.Dispose();
using var activeSubscription = Client.On(SessionLifecycleEventTypes.Created, evt => created.TrySetResult(evt));

var session = await CreateSessionAsync();
var evt = await created.Task.WaitAsync(TimeSpan.FromSeconds(10));

Assert.Equal(session.SessionId, evt.SessionId);
Assert.Equal(0, Interlocked.CompareExchange(ref count, 0, 0));
}

[Theory]
[InlineData(true)] // async dispose path (DisposeAsync)
[InlineData(false)] // sync dispose path (Dispose)
public async Task Dispose_Disconnects_Client_And_Disposes_Rpc_Surface(bool useAsyncDispose)
{
var client = Ctx.CreateClient();
await client.StartAsync();

Assert.Equal(ConnectionState.Connected, client.State);

if (useAsyncDispose)
{
await client.DisposeAsync();
}
else
{
client.Dispose();
}

Assert.Equal(ConnectionState.Disconnected, client.State);
Assert.Throws<ObjectDisposedException>(() => client.Rpc);
}
}
Loading
Loading