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
3 changes: 2 additions & 1 deletion dotnet/test/E2E/CommandsE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,9 @@ public async Task Session_With_Commands_Creates_Successfully()
[Fact]
public async Task Session_With_Commands_Resumes_Successfully()
{
var session1 = await CreateSessionAsync();
await using var session1 = await CreateSessionAsync();
var sessionId = session1.SessionId;
await SuspendAndUntrackSessionForResumeAsync(session1);

var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
{
Expand Down
2 changes: 1 addition & 1 deletion dotnet/test/E2E/RpcWorkspaceCheckpointsE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public async Task Should_Return_Null_Or_Empty_Content_For_Unknown_Checkpoint()
{
await using var session = await CreateSessionAsync();

var result = await session.Rpc.Workspaces.ReadCheckpointAsync(long.MaxValue);
var result = await session.Rpc.Workspaces.ReadCheckpointAsync(uint.MaxValue);

Assert.True(string.IsNullOrEmpty(result.Content));
}
Expand Down
40 changes: 24 additions & 16 deletions dotnet/test/E2E/SessionConfigE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,11 @@ public async Task Should_Apply_All_ReasoningEffort_Values_On_Session_Create(stri
[Trait(E2ETestTraits.Backend, E2ETestTraits.SelfConfiguredBackend)]
public async Task Should_Apply_ReasoningEffort_On_Session_Resume()
{
var originalSession = await CreateSessionAsync();
await using var originalSession = await CreateSessionAsync();
var sessionId = originalSession.SessionId;
await SuspendAndUntrackSessionForResumeAsync(originalSession);
const string reasoningModelId = "custom-reasoning-model";
var resumedSession = await ResumeSessionAsync(originalSession.SessionId, new ResumeSessionConfig
var resumedSession = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
{
Model = reasoningModelId,
Provider = CreateProxyProvider("resume-reasoning"),
Expand All @@ -187,7 +189,6 @@ public async Task Should_Apply_ReasoningEffort_On_Session_Resume()
Assert.Equal("high", resumeEvent.Data.ReasoningEffort);

await resumedSession.DisposeAsync();
await originalSession.DisposeAsync();
}

[Fact]
Expand Down Expand Up @@ -233,8 +234,9 @@ public async Task Should_Forward_Custom_Provider_Headers_On_Create()
[Trait(E2ETestTraits.Backend, E2ETestTraits.SelfConfiguredBackend)]
public async Task Should_Forward_Custom_Provider_Headers_On_Resume()
{
var session1 = await CreateSessionAsync();
await using var session1 = await CreateSessionAsync();
var sessionId = session1.SessionId;
await SuspendAndUntrackSessionForResumeAsync(session1);

var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
{
Expand Down Expand Up @@ -339,8 +341,9 @@ public async Task Should_Apply_WorkingDirectory_On_Session_Resume()
Directory.CreateDirectory(subDir);
await File.WriteAllTextAsync(Path.Join(subDir, "resume-marker.txt"), "I am in the resume working directory");

var session1 = await CreateSessionAsync();
await using var session1 = await CreateSessionAsync();
var sessionId = session1.SessionId;
await SuspendAndUntrackSessionForResumeAsync(session1);

var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
{
Expand All @@ -360,8 +363,9 @@ public async Task Should_Apply_WorkingDirectory_On_Session_Resume()
[Fact]
public async Task Should_Apply_SystemMessage_On_Session_Resume()
{
var session1 = await CreateSessionAsync();
await using var session1 = await CreateSessionAsync();
var sessionId = session1.SessionId;
await SuspendAndUntrackSessionForResumeAsync(session1);

var resumeInstruction = "End the response with RESUME_SYSTEM_MESSAGE_SENTINEL.";
var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
Expand Down Expand Up @@ -422,11 +426,13 @@ await File.WriteAllTextAsync(
Path.Join(instructionFilesDir, "extra.instructions.md"),
$"Always include {sentinel}.");

var session1 = await CreateSessionAsync(new SessionConfig
await using var session1 = await CreateSessionAsync(new SessionConfig
{
WorkingDirectory = projectDir,
});
var session2 = await ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig
var sessionId = session1.SessionId;
await SuspendAndUntrackSessionForResumeAsync(session1);
var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
{
WorkingDirectory = projectDir,
InstructionDirectories = [instructionDir],
Expand All @@ -438,14 +444,14 @@ await File.WriteAllTextAsync(
Assert.Contains(sentinel, GetSystemMessage(exchange));

await session2.DisposeAsync();
await session1.DisposeAsync();
}

[Fact]
public async Task Should_Apply_AvailableTools_On_Session_Resume()
{
var session1 = await CreateSessionAsync();
await using var session1 = await CreateSessionAsync();
var sessionId = session1.SessionId;
await SuspendAndUntrackSessionForResumeAsync(session1);

var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
{
Expand Down Expand Up @@ -493,8 +499,10 @@ public async Task Should_Apply_Session_Limits_On_Create()
[Fact]
public async Task Should_Apply_Session_Limits_On_Resume()
{
var session1 = await CreateSessionAsync();
var session2 = await ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig
await using var session1 = await CreateSessionAsync();
var sessionId = session1.SessionId;
await SuspendAndUntrackSessionForResumeAsync(session1);
var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
{
SessionLimits = new SessionLimitsConfig
{
Expand All @@ -513,7 +521,6 @@ public async Task Should_Apply_Session_Limits_On_Resume()
finally
{
await session2.DisposeAsync();
await session1.DisposeAsync();
}
}

Expand Down Expand Up @@ -558,8 +565,10 @@ public async Task Should_Apply_Excluded_Built_In_Agents_On_Resume()
{
const string excludedAgent = "explore";

var session1 = await CreateSessionAsync();
var session2 = await ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig
await using var session1 = await CreateSessionAsync();
var sessionId = session1.SessionId;
await SuspendAndUntrackSessionForResumeAsync(session1);
var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
{
ExcludedBuiltInAgents = [excludedAgent],
});
Expand All @@ -575,7 +584,6 @@ public async Task Should_Apply_Excluded_Built_In_Agents_On_Resume()
finally
{
await session2.DisposeAsync();
await session1.DisposeAsync();
}
}

Expand Down
7 changes: 3 additions & 4 deletions dotnet/test/E2E/SessionE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ public async Task Should_Create_Session_With_Custom_Tool()
[Fact]
public async Task Should_Reject_Resuming_Active_Session_Using_The_Same_Client()
{
var session1 = await CreateSessionAsync();
await using var session1 = await CreateSessionAsync();
var sessionId = session1.SessionId;

var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
Expand Down Expand Up @@ -983,8 +983,9 @@ public async Task Should_Create_Session_With_Azure_Provider()
[Trait(E2ETestTraits.Backend, E2ETestTraits.SelfConfiguredBackend)]
public async Task Should_Resume_Session_With_Custom_Provider()
{
var session = await CreateSessionAsync();
await using var session = await CreateSessionAsync();
var sessionId = session.SessionId;
await SuspendAndUntrackSessionForResumeAsync(session);

var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
{
Expand All @@ -1006,7 +1007,5 @@ public async Task Should_Resume_Session_With_Custom_Provider()
{
// disconnect may fail since the provider is fake
}

await session.DisposeAsync();
}
}
3 changes: 2 additions & 1 deletion dotnet/test/E2E/SkillsE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,13 +208,14 @@ public async Task Should_Apply_Skill_On_Session_Resume_With_SkillDirectories()
var skillsDir = CreateSkillDir();

// Create a session without skills first
var session1 = await CreateSessionAsync();
await using var session1 = await CreateSessionAsync();
var sessionId = session1.SessionId;

// First message without skill - marker should not appear
var message1 = await session1.SendAndWaitAsync(new MessageOptions { Prompt = "Say hi." });
Assert.NotNull(message1);
Assert.DoesNotContain(SkillMarker, message1!.Data.Content);
await SuspendAndUntrackSessionForResumeAsync(session1);

// Resume with skillDirectories - skill should now be active
var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
Expand Down
38 changes: 31 additions & 7 deletions dotnet/test/Harness/E2ETestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ internal static string GetTestName(ITestOutputHelper output)

public async Task InitializeAsync()
{
Ctx.PrepareForTest();
await Ctx.CleanupAfterTestAsync();
await Ctx.ConfigureForTestAsync(_snapshotCategory, _testName);
}
Expand Down Expand Up @@ -88,17 +89,40 @@ protected async Task<CopilotSession> ResumeSessionAsync(string sessionId, Resume
config ??= new ResumeSessionConfig();
config.OnPermissionRequest ??= PermissionHandler.ApproveAll;

await Client.StartAsync();
var port = Client.RuntimePort
?? throw new InvalidOperationException("The shared E2E client must use TCP transport to support multi-client resume.");

var client = Ctx.CreateClient(options: new CopilotClientOptions
CopilotClient client;
if (E2ETestContext.UsesInProcessTransport)
{
client = Client;
}
else
{
Connection = RuntimeConnection.ForUri($"localhost:{port}", connectionToken: E2ETestFixture.SharedTcpConnectionToken),
});
await Client.StartAsync();
var port = Client.RuntimePort
?? throw new InvalidOperationException("The shared E2E client must use TCP transport to support multi-client resume.");

client = Ctx.CreateClient(options: new CopilotClientOptions
{
Connection = RuntimeConnection.ForUri($"localhost:{port}", connectionToken: E2ETestFixture.SharedTcpConnectionToken),
});
}

return await Ctx.ResumeSessionAsync(client, sessionId, config);
}

protected static async Task SuspendAndUntrackSessionForResumeAsync(CopilotSession session)
{
await session.Rpc.SuspendAsync();

// In-process clients host separate runtimes, while session.destroy removes the
// session from the current runtime. Untrack locally to exercise resume without
// either replacing an active wrapper or destroying the session first.
var removeFromClient = typeof(CopilotSession).GetMethod(
"RemoveFromClient",
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("CopilotSession.RemoveFromClient was not found.");
removeFromClient.Invoke(session, null);
}

protected static string GetSystemMessage(ParsedHttpExchange exchange)
{
return exchange.Request.Messages.FirstOrDefault(m => m.Role == "system")?.StringContent ?? string.Empty;
Expand Down
40 changes: 25 additions & 15 deletions dotnet/test/Harness/E2ETestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public sealed class E2ETestContext : IAsyncDisposable
public string HomeDir { get; }
public string WorkDir { get; }
public string ProxyUrl { get; }
internal static bool UsesInProcessTransport => IsInProcess(null);

/// <summary>Optional logger injected by tests; applied to all clients created via <see cref="CreateClient"/>.</summary>
public ILogger? Logger { get; set; }
Expand Down Expand Up @@ -322,22 +323,8 @@ public CopilotClient CreateClient(

if (IsInProcess(options.Connection))
{
// In-process hosting: runtime code runs host-side in this process (the
// loaded cdylib) and reads the ambient process environment rather than
// the environment passed to copilot_runtime_host_start, so the per-test
// redirects, cleared tokens/HMAC, and isolated home must be mirrored
// onto this process's real environment. Restored after each test by
// InProcessEnvIsolationAttribute.
foreach (var (name, value) in env)
{
InProcessEnvIsolation.Apply(name, value);
}

// A per-client WorkingDirectory is rejected in-process; instead point this
// process's cwd at the desired directory so the worker inherits it at spawn
// (restored after the test by InProcessEnvIsolationAttribute).
options.WorkingDirectory = null;
InProcessEnvIsolation.SetWorkingDirectory(desiredWorkingDirectory);
ApplyInProcessEnvironment(env, desiredWorkingDirectory);
}
else if (options.Connection is ChildProcessRuntimeConnection child)
{
Expand Down Expand Up @@ -395,6 +382,29 @@ public Task<CopilotSession> ResumeSessionAsync(
return client.ResumeSessionAsync(sessionId, config);
}

internal void PrepareForTest()
{
if (UsesInProcessTransport)
{
ApplyInProcessEnvironment(GetEnvironment(), WorkDir);
}
}

private static void ApplyInProcessEnvironment(IReadOnlyDictionary<string, string> environment, string workingDirectory)
{
// Runtime code runs host-side in this process and reads its ambient environment,
// so restore the per-test redirects and isolated home after the assembly-level
// isolation attribute reset them at the end of the preceding test.
foreach (var (name, value) in environment)
{
InProcessEnvIsolation.Apply(name, value);
}

// The worker inherits the host process cwd because the native host has no
// per-client working-directory parameter.
InProcessEnvIsolation.SetWorkingDirectory(workingDirectory);
}

public void UntrackClient(CopilotClient client)
{
lock (_clientsLock)
Expand Down
7 changes: 6 additions & 1 deletion dotnet/test/Harness/E2ETestFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,15 @@ public async Task InitializeAsync()
Ctx = await E2ETestContext.CreateAsync();
Client = Ctx.CreateClient(options: new CopilotClientOptions
{
Connection = RuntimeConnection.ForTcp(connectionToken: SharedTcpConnectionToken),
Connection = CreateSharedConnection(E2ETestContext.UsesInProcessTransport),
}, persistent: true);
}

internal static RuntimeConnection CreateSharedConnection(bool useInProcessTransport) =>
useInProcessTransport
? RuntimeConnection.ForInProcess()
: RuntimeConnection.ForTcp(connectionToken: SharedTcpConnectionToken);

public async Task DisposeAsync()
{
await Ctx.DisposeAsync();
Expand Down
27 changes: 27 additions & 0 deletions dotnet/test/Unit/E2ETestFixtureTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

using Xunit;

namespace GitHub.Copilot.Test.Unit;

public class E2ETestFixtureTests
{
[Fact]
public void Shared_Client_Uses_InProcess_Connection_For_InProcess_Tests()
{
var connection = E2ETestFixture.CreateSharedConnection(useInProcessTransport: true);

Assert.IsType<InProcessRuntimeConnection>(connection);
}

[Fact]
public void Shared_Client_Preserves_Tcp_Connection_For_OutOfProcess_Tests()
{
var connection = Assert.IsType<TcpRuntimeConnection>(
E2ETestFixture.CreateSharedConnection(useInProcessTransport: false));

Assert.Equal(E2ETestFixture.SharedTcpConnectionToken, connection.ConnectionToken);
}
}
Loading