forked from github/copilot-sdk
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathRpcExtensionsLoadedE2ETests.cs
More file actions
350 lines (299 loc) · 14 KB
/
Copy pathRpcExtensionsLoadedE2ETests.cs
File metadata and controls
350 lines (299 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/
using GitHub.Copilot.Rpc;
using GitHub.Copilot.Test.Harness;
using System.Diagnostics;
using Xunit;
using Xunit.Abstractions;
using RpcExtension = GitHub.Copilot.Rpc.Extension;
namespace GitHub.Copilot.Test.E2E;
/// <summary>
/// E2E coverage for the loaded-extensions code path in the runtime: when the
/// experimental EXTENSIONS feature flag is enabled and a session is created
/// with EnableConfigDiscovery=true, the runtime discovers user/project
/// extensions from disk, forks each one as a subprocess, and exposes
/// session.Rpc.Extensions.{List,Enable,Disable,Reload}.
///
/// The "controller absent" path is already covered by
/// <c>RpcMcpAndSkillsE2ETests.Should_Report_Error_When_Extensions_Are_Not_Available</c>;
/// these tests cover the controller-present path.
/// </summary>
public class RpcExtensionsLoadedE2ETests(E2ETestFixture fixture, ITestOutputHelper output)
: E2ETestBase(fixture, "rpc_extensions_loaded", output)
{
/// <summary>
/// Extension subprocess startup involves Node fork + SDK resolver + JSON-RPC
/// handshake. Empirically this completes in well under a second on Windows,
/// but the runtime's READY_TIMEOUT_MS is 30s, so we use the same upper bound
/// to keep the test bulletproof on cold starts.
/// </summary>
private static readonly TimeSpan ExtensionStartupTimeout = TimeSpan.FromSeconds(45);
/// <summary>
/// Builds an environment dict that opts the runtime into the experimental
/// EXTENSIONS feature flag while preserving every other harness-managed
/// var (COPILOT_API_URL, COPILOT_HOME, NODE_V8_COVERAGE, etc).
/// </summary>
private Dictionary<string, string> ExtensionsEnabledEnvironment()
{
var env = new Dictionary<string, string>(Ctx.GetEnvironment())
{
["COPILOT_CLI_ENABLED_FEATURE_FLAGS"] = "EXTENSIONS",
};
return env;
}
/// <summary>
/// Creates a client with the EXTENSIONS feature flag and --yolo CLI arg.
/// --yolo auto-approves extension permission gates at the CLI level,
/// preventing tests from breaking when new permission gates are added
/// (e.g., extension-permission-access from copilot-agent-runtime#6024).
/// </summary>
private CopilotClient CreateExtensionsClient()
{
return Ctx.CreateClient(options: new CopilotClientOptions
{
Connection = RuntimeConnection.ForStdio(args: ["--yolo"]),
Environment = ExtensionsEnabledEnvironment(),
});
}
/// <summary>
/// Writes a minimal user extension into <c>{HomeDir}/extensions/{name}/extension.mjs</c>.
/// The body imports <c>@github/copilot-sdk/extension</c>, calls <c>joinSession</c>
/// to establish the JSON-RPC handshake (so the extension transitions from
/// "starting" → "running" quickly), and then keeps the process alive.
/// Returns the unique extension name.
/// </summary>
private string CreateUserExtension(string? prefix = null)
{
var extName = Path.GetFileName($"{prefix ?? "test-ext"}-{Guid.NewGuid():N}");
var extDir = Path.Join(Ctx.HomeDir, "extensions", extName);
WriteRunningExtension(extDir);
return extName;
}
private async Task<(string Name, string Id, string WorkingDirectory)> CreateProjectExtensionAsync(string? prefix = null)
{
var extName = Path.GetFileName($"{prefix ?? "project-ext"}-{Guid.NewGuid():N}");
var projectDirName = Path.GetFileName($"extension-project-{Guid.NewGuid():N}");
var projectDir = Path.Join(Ctx.WorkDir, projectDirName);
Directory.CreateDirectory(projectDir);
await InitializeGitRepositoryAsync(projectDir);
var extDir = Path.Join(projectDir, ".github", "extensions", extName);
WriteRunningExtension(extDir);
return (extName, $"project:{extName}", projectDir);
}
private static void WriteRunningExtension(string extDir)
{
Directory.CreateDirectory(extDir);
var body = """
import { joinSession } from "@github/copilot-sdk/extension";
// Establish the JSON-RPC handshake so the runtime sees us as ready.
await joinSession({});
// Keep the process alive so the runtime doesn't reap us as exited.
// The unref() ensures we still exit when the parent disconnects.
setInterval(() => {}, 60_000).unref?.();
""";
File.WriteAllText(Path.Join(extDir, "extension.mjs"), body);
}
private static async Task InitializeGitRepositoryAsync(string projectDir)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo("git")
{
WorkingDirectory = projectDir,
Arguments = "init -q",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
}
};
if (!process.Start())
{
throw new InvalidOperationException("Failed to start git init.");
}
await process.WaitForExitAsync();
if (process.ExitCode != 0)
{
var stderr = await process.StandardError.ReadToEndAsync();
throw new InvalidOperationException($"git init failed with exit code {process.ExitCode}: {stderr}");
}
}
/// <summary>
/// Polls <c>session.Rpc.Extensions.ListAsync()</c> until the controller
/// becomes available AND the named extension reaches a terminal status
/// (running, failed, or disabled). The controller is set asynchronously
/// after session create returns, and list calls can report an empty list
/// until setup finishes.
/// </summary>
private static async Task<RpcExtension> WaitForExtensionAsync(
CopilotSession session,
string extensionId,
ExtensionStatus expectedStatus,
TimeSpan? timeout = null)
{
RpcExtension? lastSeen = null;
await TestHelper.WaitForConditionAsync(
async () =>
{
var list = await session.Rpc.Extensions.ListAsync();
lastSeen = list.Extensions.FirstOrDefault(e => string.Equals(e.Id, extensionId, StringComparison.Ordinal));
return lastSeen != null && lastSeen.Status == expectedStatus;
},
timeout: timeout ?? ExtensionStartupTimeout,
timeoutMessage: $"Extension '{extensionId}' did not reach status '{expectedStatus}' (last seen: {lastSeen?.Status.ToString() ?? "<not present>"}).",
transientExceptionFilter: ex => ex.ToString().Contains("Extensions not available", StringComparison.OrdinalIgnoreCase),
pollInterval: TimeSpan.FromMilliseconds(100));
return lastSeen!;
}
[Theory]
[InlineData("user")]
[InlineData("project")]
public async Task Discovers_Loads_And_Reports_Running_Extension(string sourceValue)
{
var source = new ExtensionSource(sourceValue);
string extName;
string extId;
string? workingDirectory;
if (source == ExtensionSource.User)
{
extName = CreateUserExtension();
extId = $"user:{extName}";
workingDirectory = null;
}
else if (source == ExtensionSource.Project)
{
(extName, extId, workingDirectory) = await CreateProjectExtensionAsync();
}
else
{
throw new ArgumentOutOfRangeException(nameof(sourceValue), sourceValue, null);
}
await using var client = CreateExtensionsClient();
await using var session = await client.CreateSessionAsync(new SessionConfig
{
EnableConfigDiscovery = true,
WorkingDirectory = workingDirectory,
OnPermissionRequest = PermissionHandler.ApproveAll,
});
var ext = await WaitForExtensionAsync(session, extId, ExtensionStatus.Running);
Assert.Equal(extId, ext.Id);
Assert.Equal(extName, ext.Name);
Assert.Equal(source, ext.Source);
Assert.Equal(ExtensionStatus.Running, ext.Status);
Assert.NotNull(ext.Pid);
Assert.True(ext.Pid > 0);
}
[Fact]
public async Task Disable_Then_Enable_Cycles_Extension_Status()
{
var extName = CreateUserExtension();
var extId = $"user:{extName}";
await using var client = CreateExtensionsClient();
await using var session = await client.CreateSessionAsync(new SessionConfig
{
EnableConfigDiscovery = true,
OnPermissionRequest = PermissionHandler.ApproveAll,
});
// Wait until the initial running state is observed before mutating.
await WaitForExtensionAsync(session, extId, ExtensionStatus.Running);
// Disable: the extension should transition to "disabled" and have no pid.
await session.Rpc.Extensions.DisableAsync(extId);
var disabled = await WaitForExtensionAsync(session, extId, ExtensionStatus.Disabled);
Assert.Null(disabled.Pid);
// Re-enable: the extension is reloaded as a fresh subprocess.
await session.Rpc.Extensions.EnableAsync(extId);
var reEnabled = await WaitForExtensionAsync(session, extId, ExtensionStatus.Running);
Assert.NotNull(reEnabled.Pid);
}
[Fact]
public async Task Reload_Picks_Up_Extension_Added_After_Session_Create()
{
// Start the session BEFORE writing the extension so the initial discovery sees nothing.
await using var client = CreateExtensionsClient();
await using var session = await client.CreateSessionAsync(new SessionConfig
{
EnableConfigDiscovery = true,
OnPermissionRequest = PermissionHandler.ApproveAll,
});
// setupExtensionsForSession runs asynchronously; until it completes the
// controller isn't installed and ReloadAsync throws "Extensions not
// available". (ListAsync returns {extensions: []} either way and is
// therefore not a usable probe here.) Poll Reload directly.
var extName = CreateUserExtension(prefix: "reloadable-ext");
var extId = $"user:{extName}";
await TestHelper.WaitForConditionAsync(
async () =>
{
await session.Rpc.Extensions.ReloadAsync();
return true;
},
timeout: ExtensionStartupTimeout,
timeoutMessage: "Extensions controller never became available for ReloadAsync.",
transientExceptionFilter: ex => ex.ToString().Contains("Extensions not available", StringComparison.OrdinalIgnoreCase),
pollInterval: TimeSpan.FromMilliseconds(100));
var ext = await WaitForExtensionAsync(session, extId, ExtensionStatus.Running);
Assert.Equal(ExtensionSource.User, ext.Source);
}
[Fact]
public async Task Failed_Extension_Reports_Failed_Status()
{
// Write an extension whose body throws synchronously at import time.
// The bootstrap will fork the child, the import will throw, the child
// exits with code 1, and the runtime should mark it as "failed".
var extName = $"crashing-ext-{Guid.NewGuid():N}";
var extDir = Path.Join(Ctx.HomeDir, "extensions", extName);
Directory.CreateDirectory(extDir);
File.WriteAllText(
Path.Join(extDir, "extension.mjs"),
"throw new Error('intentional startup failure');");
var extId = $"user:{extName}";
await using var client = CreateExtensionsClient();
await using var session = await client.CreateSessionAsync(new SessionConfig
{
EnableConfigDiscovery = true,
OnPermissionRequest = PermissionHandler.ApproveAll,
});
var ext = await WaitForExtensionAsync(session, extId, ExtensionStatus.Failed);
Assert.Equal(extId, ext.Id);
Assert.Equal(ExtensionSource.User, ext.Source);
}
[Fact]
public async Task Multiple_Extensions_Are_Discovered_Independently()
{
var ext1Name = CreateUserExtension(prefix: "multi-a");
var ext2Name = CreateUserExtension(prefix: "multi-b");
var ext1Id = $"user:{ext1Name}";
var ext2Id = $"user:{ext2Name}";
await using var client = CreateExtensionsClient();
await using var session = await client.CreateSessionAsync(new SessionConfig
{
EnableConfigDiscovery = true,
OnPermissionRequest = PermissionHandler.ApproveAll,
});
await WaitForExtensionAsync(session, ext1Id, ExtensionStatus.Running);
await WaitForExtensionAsync(session, ext2Id, ExtensionStatus.Running);
var list = await session.Rpc.Extensions.ListAsync();
var pids = list.Extensions.Select(e => e.Pid).Where(p => p.HasValue).ToList();
Assert.Equal(pids.Count, pids.Distinct().Count());
}
[Fact]
public async Task Reload_Preserves_Disabled_State_Across_Calls()
{
var extName = CreateUserExtension(prefix: "persistent-disable");
var extId = $"user:{extName}";
await using var client = CreateExtensionsClient();
await using var session = await client.CreateSessionAsync(new SessionConfig
{
EnableConfigDiscovery = true,
OnPermissionRequest = PermissionHandler.ApproveAll,
});
await WaitForExtensionAsync(session, extId, ExtensionStatus.Running);
await session.Rpc.Extensions.DisableAsync(extId);
await WaitForExtensionAsync(session, extId, ExtensionStatus.Disabled);
// Reload re-runs discovery and respects the per-session disabled set,
// so the extension stays disabled and is not re-launched.
await session.Rpc.Extensions.ReloadAsync();
var afterReload = await WaitForExtensionAsync(session, extId, ExtensionStatus.Disabled);
Assert.Null(afterReload.Pid);
}
}