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
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