Skip to content

Commit e0c68d2

Browse files
committed
Isolate real Codex tests from local MCP config
1 parent e07ace1 commit e0c68d2

File tree

7 files changed

+267
-42
lines changed

7 files changed

+267
-42
lines changed

CodexSharpSDK.Tests/Integration/CodexExecIntegrationTests.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ public class CodexExecIntegrationTests
1414
[Test]
1515
public async Task RunAsync_UsesDefaultProcessRunner_EndToEnd()
1616
{
17-
var settings = RealCodexTestSupport.GetRequiredSettings();
17+
using var settings = RealCodexTestSupport.GetRequiredSettings();
1818

19-
var exec = new CodexExec();
19+
var exec = RealCodexTestSupport.CreateExec(settings);
2020
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(2));
2121

2222
var lines = await DrainToListAsync(exec.RunAsync(new CodexExecArgs
@@ -37,9 +37,9 @@ public async Task RunAsync_UsesDefaultProcessRunner_EndToEnd()
3737
[Test]
3838
public async Task RunAsync_SecondCallPassesResumeArgument_EndToEnd()
3939
{
40-
var settings = RealCodexTestSupport.GetRequiredSettings();
40+
using var settings = RealCodexTestSupport.GetRequiredSettings();
4141

42-
using var client = RealCodexTestSupport.CreateClient();
42+
using var client = RealCodexTestSupport.CreateClient(settings);
4343
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(3));
4444

4545
var thread = client.StartThread(new ThreadOptions
@@ -70,9 +70,9 @@ public async Task RunAsync_SecondCallPassesResumeArgument_EndToEnd()
7070
[Test]
7171
public async Task RunAsync_PropagatesNonZeroExitCode_EndToEnd()
7272
{
73-
var settings = RealCodexTestSupport.GetRequiredSettings();
73+
using var settings = RealCodexTestSupport.GetRequiredSettings();
7474

75-
var exec = new CodexExec();
75+
var exec = RealCodexTestSupport.CreateExec(settings);
7676
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(2));
7777

7878
var action = async () => await DrainAsync(exec.RunAsync(new CodexExecArgs

CodexSharpSDK.Tests/Integration/RealCodexIntegrationTests.cs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ public class RealCodexIntegrationTests
1212
[Test]
1313
public async Task RunAsync_WithRealCodexCli_ReturnsStructuredOutput()
1414
{
15-
var settings = RealCodexTestSupport.GetRequiredSettings();
15+
using var settings = RealCodexTestSupport.GetRequiredSettings();
1616

17-
using var client = RealCodexTestSupport.CreateClient();
17+
using var client = RealCodexTestSupport.CreateClient(settings);
1818
var thread = StartRealIntegrationThread(client, settings.Model);
1919

2020
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(2));
@@ -33,9 +33,9 @@ public async Task RunAsync_WithRealCodexCli_ReturnsStructuredOutput()
3333
[Test]
3434
public async Task RunStreamedAsync_WithRealCodexCli_YieldsCompletedTurnEvent()
3535
{
36-
var settings = RealCodexTestSupport.GetRequiredSettings();
36+
using var settings = RealCodexTestSupport.GetRequiredSettings();
3737

38-
using var client = RealCodexTestSupport.CreateClient();
38+
using var client = RealCodexTestSupport.CreateClient(settings);
3939
var thread = StartRealIntegrationThread(client, settings.Model);
4040
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(2));
4141

@@ -63,9 +63,9 @@ public async Task RunStreamedAsync_WithRealCodexCli_YieldsCompletedTurnEvent()
6363
[Test]
6464
public async Task RunAsync_WithRealCodexCli_SecondTurnKeepsThreadId()
6565
{
66-
var settings = RealCodexTestSupport.GetRequiredSettings();
66+
using var settings = RealCodexTestSupport.GetRequiredSettings();
6767

68-
using var client = RealCodexTestSupport.CreateClient();
68+
using var client = RealCodexTestSupport.CreateClient(settings);
6969
var thread = StartRealIntegrationThread(client, settings.Model);
7070
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(3));
7171

@@ -95,9 +95,9 @@ public async Task RunAsync_WithRealCodexCli_SecondTurnKeepsThreadId()
9595
[Test]
9696
public async Task RunAsync_WithExplicitNonEphemeralOverride_PersistsRolloutWhenClientConfigEnablesEphemeral()
9797
{
98-
var settings = RealCodexTestSupport.GetRequiredSettings();
98+
using var settings = RealCodexTestSupport.GetRequiredSettings();
9999

100-
using var client = RealCodexTestSupport.CreateClient(new CodexOptions
100+
using var client = RealCodexTestSupport.CreateClient(settings, new CodexOptions
101101
{
102102
Config = new JsonObject
103103
{
@@ -123,6 +123,7 @@ public async Task RunAsync_WithExplicitNonEphemeralOverride_PersistsRolloutWhenC
123123
await Assert.That(thread.Id).IsNotNull();
124124

125125
var rolloutPath = await RealCodexTestSupport.FindPersistedRolloutPathAsync(
126+
settings,
126127
thread.Id!,
127128
TimeSpan.FromSeconds(10));
128129

CodexSharpSDK.Tests/Shared/RealCodexTestSupport.cs

Lines changed: 235 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,39 @@
1+
using System.Collections;
12
using System.Diagnostics;
23
using ManagedCode.CodexSharpSDK.Client;
34
using ManagedCode.CodexSharpSDK.Configuration;
5+
using ManagedCode.CodexSharpSDK.Execution;
46
using ManagedCode.CodexSharpSDK.Internal;
7+
using Microsoft.Extensions.Logging;
58

69
namespace ManagedCode.CodexSharpSDK.Tests.Shared;
710

811
internal static class RealCodexTestSupport
912
{
1013
private const string ModelEnvVar = "CODEX_TEST_MODEL";
14+
private const string SolutionFileName = "ManagedCode.CodexSharpSDK.slnx";
15+
private const string TestsDirectoryName = "tests";
16+
private const string SandboxDirectoryName = ".sandbox";
17+
private const string SandboxPrefix = "RealCodexTestSupport-";
18+
private const string CodexHomeEnvironmentVariable = "CODEX_HOME";
19+
private const string HomeEnvironmentVariable = "HOME";
20+
private const string UserProfileEnvironmentVariable = "USERPROFILE";
21+
private const string XdgConfigHomeEnvironmentVariable = "XDG_CONFIG_HOME";
22+
private const string AppDataEnvironmentVariable = "APPDATA";
23+
private const string LocalAppDataEnvironmentVariable = "LOCALAPPDATA";
24+
private const string OpenAiApiKeyEnvironmentVariable = "OPENAI_API_KEY";
25+
private const string OpenAiBaseUrlEnvironmentVariable = "OPENAI_BASE_URL";
26+
private const string CodexApiKeyEnvironmentVariable = "CODEX_API_KEY";
27+
private const string CodexHomeDirectoryName = ".codex";
28+
private const string ConfigDirectoryName = ".config";
29+
private const string AppDataDirectoryName = "AppData";
30+
private const string RoamingDirectoryName = "Roaming";
31+
private const string LocalDirectoryName = "Local";
32+
private const string ConfigFileName = "config.toml";
33+
private const string SessionsDirectoryName = "sessions";
34+
private const string AuthFileName = "auth.json";
35+
private const string KosAuthFileName = "kos-auth.json";
36+
private const string SandboxCleanupFailureDataKey = "SandboxCleanupFailure";
1137

1238
public static RealCodexTestSettings GetRequiredSettings()
1339
{
@@ -17,20 +43,46 @@ public static RealCodexTestSettings GetRequiredSettings()
1743
"Real Codex tests require the codex CLI. Install it first and ensure it is available in PATH.");
1844
}
1945

20-
return new RealCodexTestSettings(ResolveModel());
46+
var sandboxDirectory = CreateSandboxDirectory();
47+
try
48+
{
49+
var model = ResolveModel();
50+
var environmentOverrides = CreateAuthenticatedEnvironmentOverrides(sandboxDirectory, model);
51+
return new RealCodexTestSettings(
52+
model,
53+
sandboxDirectory,
54+
environmentOverrides[CodexHomeEnvironmentVariable],
55+
environmentOverrides);
56+
}
57+
catch (Exception exception)
58+
{
59+
AttachCleanupFailure(exception, sandboxDirectory);
60+
throw;
61+
}
2162
}
2263

23-
public static CodexClient CreateClient(CodexOptions? options = null)
64+
public static CodexClient CreateClient(RealCodexTestSettings settings, CodexOptions? options = null)
2465
{
25-
return new CodexClient(options ?? new CodexOptions());
66+
ArgumentNullException.ThrowIfNull(settings);
67+
return new CodexClient(CreateCodexOptions(settings, options));
2668
}
2769

28-
public static async Task<string?> FindPersistedRolloutPathAsync(string threadId, TimeSpan timeout)
70+
public static CodexExec CreateExec(RealCodexTestSettings settings, ILogger? logger = null)
2971
{
72+
ArgumentNullException.ThrowIfNull(settings);
73+
return new CodexExec(environmentOverride: settings.EnvironmentOverrides, logger: logger);
74+
}
75+
76+
public static async Task<string?> FindPersistedRolloutPathAsync(
77+
RealCodexTestSettings settings,
78+
string threadId,
79+
TimeSpan timeout)
80+
{
81+
ArgumentNullException.ThrowIfNull(settings);
3082
ArgumentException.ThrowIfNullOrWhiteSpace(threadId);
3183

32-
var sessionsPath = GetCodexSessionsPath();
33-
if (sessionsPath is null || !Directory.Exists(sessionsPath))
84+
var sessionsPath = Path.Combine(settings.CodexHomeDirectory, SessionsDirectoryName);
85+
if (!Directory.Exists(sessionsPath))
3486
{
3587
return null;
3688
}
@@ -127,24 +179,24 @@ private static string ResolveModel()
127179

128180
private static string? GetCodexConfigPath()
129181
{
130-
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
131-
if (string.IsNullOrWhiteSpace(homeDirectory))
182+
var codexHomeDirectory = GetSourceCodexHomePath();
183+
if (string.IsNullOrWhiteSpace(codexHomeDirectory))
132184
{
133185
return null;
134186
}
135187

136-
return Path.Combine(homeDirectory, ".codex", "config.toml");
188+
return Path.Combine(codexHomeDirectory, ConfigFileName);
137189
}
138190

139-
private static string? GetCodexSessionsPath()
191+
private static string? GetSourceCodexHomePath()
140192
{
141193
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
142194
if (string.IsNullOrWhiteSpace(homeDirectory))
143195
{
144196
return null;
145197
}
146198

147-
return Path.Combine(homeDirectory, ".codex", "sessions");
199+
return Path.Combine(homeDirectory, CodexHomeDirectoryName);
148200
}
149201

150202
private static bool IsCodexAvailable()
@@ -160,6 +212,177 @@ private static bool IsCodexAvailable()
160212
OperatingSystem.IsWindows(),
161213
out _);
162214
}
215+
216+
private static CodexOptions CreateCodexOptions(RealCodexTestSettings settings, CodexOptions? options)
217+
{
218+
var environmentOverrides = new Dictionary<string, string>(settings.EnvironmentOverrides, StringComparer.Ordinal);
219+
if (options?.EnvironmentVariables is not null)
220+
{
221+
foreach (var (key, value) in options.EnvironmentVariables)
222+
{
223+
environmentOverrides[key] = value;
224+
}
225+
}
226+
227+
return new CodexOptions
228+
{
229+
CodexExecutablePath = options?.CodexExecutablePath,
230+
BaseUrl = options?.BaseUrl,
231+
ApiKey = options?.ApiKey,
232+
Config = options?.Config,
233+
EnvironmentVariables = environmentOverrides,
234+
Logger = options?.Logger,
235+
};
236+
}
237+
238+
private static Dictionary<string, string> CreateAuthenticatedEnvironmentOverrides(string sandboxDirectory, string model)
239+
{
240+
var codexHome = Path.Combine(sandboxDirectory, CodexHomeDirectoryName);
241+
var configHome = Path.Combine(sandboxDirectory, ConfigDirectoryName);
242+
var appData = Path.Combine(sandboxDirectory, AppDataDirectoryName, RoamingDirectoryName);
243+
var localAppData = Path.Combine(sandboxDirectory, AppDataDirectoryName, LocalDirectoryName);
244+
245+
Directory.CreateDirectory(codexHome);
246+
Directory.CreateDirectory(configHome);
247+
Directory.CreateDirectory(appData);
248+
Directory.CreateDirectory(localAppData);
249+
250+
WriteIsolatedCodexConfig(codexHome, model);
251+
CopyAuthenticationArtifacts(codexHome);
252+
253+
var environmentOverrides = Environment.GetEnvironmentVariables()
254+
.Cast<DictionaryEntry>()
255+
.Where(entry => entry.Key is string && entry.Value is not null)
256+
.ToDictionary(
257+
entry => entry.Key.ToString() ?? string.Empty,
258+
entry => entry.Value?.ToString() ?? string.Empty,
259+
StringComparer.Ordinal);
260+
261+
environmentOverrides[CodexHomeEnvironmentVariable] = codexHome;
262+
environmentOverrides[HomeEnvironmentVariable] = sandboxDirectory;
263+
environmentOverrides[UserProfileEnvironmentVariable] = sandboxDirectory;
264+
environmentOverrides[XdgConfigHomeEnvironmentVariable] = configHome;
265+
environmentOverrides[AppDataEnvironmentVariable] = appData;
266+
environmentOverrides[LocalAppDataEnvironmentVariable] = localAppData;
267+
environmentOverrides[OpenAiApiKeyEnvironmentVariable] = string.Empty;
268+
environmentOverrides[OpenAiBaseUrlEnvironmentVariable] = string.Empty;
269+
environmentOverrides[CodexApiKeyEnvironmentVariable] = string.Empty;
270+
271+
return environmentOverrides;
272+
}
273+
274+
private static void WriteIsolatedCodexConfig(string codexHomeDirectory, string model)
275+
{
276+
var escapedModel = model
277+
.Replace("\\", "\\\\", StringComparison.Ordinal)
278+
.Replace("\"", "\\\"", StringComparison.Ordinal);
279+
var configPath = Path.Combine(codexHomeDirectory, ConfigFileName);
280+
File.WriteAllText(configPath, $"model = \"{escapedModel}\"{Environment.NewLine}");
281+
}
282+
283+
private static void CopyAuthenticationArtifacts(string codexHomeDirectory)
284+
{
285+
var sourceCodexHome = GetSourceCodexHomePath();
286+
if (string.IsNullOrWhiteSpace(sourceCodexHome) || !Directory.Exists(sourceCodexHome))
287+
{
288+
throw new InvalidOperationException(
289+
"Real Codex tests require an existing local Codex login. Run 'codex login' first.");
290+
}
291+
292+
var copiedFiles = 0;
293+
copiedFiles += CopyAuthenticationArtifactIfExists(sourceCodexHome, codexHomeDirectory, AuthFileName);
294+
copiedFiles += CopyAuthenticationArtifactIfExists(sourceCodexHome, codexHomeDirectory, KosAuthFileName);
295+
296+
if (copiedFiles == 0)
297+
{
298+
throw new InvalidOperationException(
299+
"Real Codex tests require an existing local Codex login. Run 'codex login' first.");
300+
}
301+
}
302+
303+
private static int CopyAuthenticationArtifactIfExists(
304+
string sourceCodexHome,
305+
string destinationCodexHome,
306+
string fileName)
307+
{
308+
var sourcePath = Path.Combine(sourceCodexHome, fileName);
309+
if (!File.Exists(sourcePath))
310+
{
311+
return 0;
312+
}
313+
314+
var destinationPath = Path.Combine(destinationCodexHome, fileName);
315+
File.Copy(sourcePath, destinationPath, overwrite: true);
316+
return 1;
317+
}
318+
319+
private static string CreateSandboxDirectory()
320+
{
321+
var repositoryRoot = ResolveRepositoryRootPath();
322+
var sandboxDirectory = Path.Combine(
323+
repositoryRoot,
324+
TestsDirectoryName,
325+
SandboxDirectoryName,
326+
$"{SandboxPrefix}{Guid.NewGuid():N}");
327+
Directory.CreateDirectory(sandboxDirectory);
328+
return sandboxDirectory;
329+
}
330+
331+
private static string ResolveRepositoryRootPath()
332+
{
333+
var current = new DirectoryInfo(AppContext.BaseDirectory);
334+
while (current is not null)
335+
{
336+
if (File.Exists(Path.Combine(current.FullName, SolutionFileName)))
337+
{
338+
return current.FullName;
339+
}
340+
341+
current = current.Parent;
342+
}
343+
344+
throw new InvalidOperationException("Could not locate repository root from test execution directory.");
345+
}
346+
347+
private static void AttachCleanupFailure(Exception originalException, string sandboxDirectory)
348+
{
349+
try
350+
{
351+
if (Directory.Exists(sandboxDirectory))
352+
{
353+
Directory.Delete(sandboxDirectory, recursive: true);
354+
}
355+
}
356+
catch (IOException cleanupException)
357+
{
358+
originalException.Data[SandboxCleanupFailureDataKey] = cleanupException;
359+
}
360+
catch (UnauthorizedAccessException cleanupException)
361+
{
362+
originalException.Data[SandboxCleanupFailureDataKey] = cleanupException;
363+
}
364+
}
163365
}
164366

165-
internal sealed record RealCodexTestSettings(string Model);
367+
internal sealed class RealCodexTestSettings(
368+
string model,
369+
string sandboxDirectory,
370+
string codexHomeDirectory,
371+
IReadOnlyDictionary<string, string> environmentOverrides) : IDisposable
372+
{
373+
public string Model { get; } = model;
374+
375+
public string SandboxDirectory { get; } = sandboxDirectory;
376+
377+
public string CodexHomeDirectory { get; } = codexHomeDirectory;
378+
379+
public IReadOnlyDictionary<string, string> EnvironmentOverrides { get; } = environmentOverrides;
380+
381+
public void Dispose()
382+
{
383+
if (Directory.Exists(SandboxDirectory))
384+
{
385+
Directory.Delete(SandboxDirectory, recursive: true);
386+
}
387+
}
388+
}

0 commit comments

Comments
 (0)