/ tests / csharp / OpenSandbox.E2ETests / SandboxE2ETests.cs
SandboxE2ETests.cs
   1  // Copyright 2026 Alibaba Group Holding Ltd.
   2  //
   3  // Licensed under the Apache License, Version 2.0 (the "License");
   4  // you may not use this file except in compliance with the License.
   5  // You may obtain a copy of the License at
   6  //
   7  //     http://www.apache.org/licenses/LICENSE-2.0
   8  //
   9  // Unless required by applicable law or agreed to in writing, software
  10  // distributed under the License is distributed on an "AS IS" BASIS,
  11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12  // See the License for the specific language governing permissions and
  13  // limitations under the License.
  14  
  15  using System.Collections.Concurrent;
  16  using System.Text;
  17  using OpenSandbox.Config;
  18  using OpenSandbox.Core;
  19  using OpenSandbox.Models;
  20  using Xunit;
  21  
  22  namespace OpenSandbox.E2ETests;
  23  
  24  [Collection("CSharp E2E Tests")]
  25  public class SandboxE2ETests : IClassFixture<SandboxE2ETestFixture>
  26  {
  27      private readonly SandboxE2ETestFixture _fixture;
  28  
  29      public SandboxE2ETests(SandboxE2ETestFixture fixture)
  30      {
  31          _fixture = fixture;
  32      }
  33  
  34      [Fact(Timeout = 2 * 60 * 1000)]
  35      public async Task Sandbox_Lifecycle_Health_Endpoint_Metrics_Renew_Connect()
  36      {
  37          var sandbox = _fixture.Sandbox;
  38          Assert.False(string.IsNullOrWhiteSpace(sandbox.Id));
  39          Assert.True(await sandbox.IsHealthyAsync());
  40  
  41          var info = await sandbox.GetInfoAsync();
  42          Assert.Equal(sandbox.Id, info.Id);
  43          Assert.Equal(SandboxStates.Running, info.Status.State);
  44          Assert.Equal(Constants.DefaultEntrypoint, info.Entrypoint);
  45          Assert.NotNull(info.Metadata);
  46          Assert.Equal("csharp-e2e-test", info.Metadata!["tag"]);
  47          Assert.True(info.ExpiresAt > info.CreatedAt);
  48  
  49          var endpoint = await sandbox.GetEndpointAsync(Constants.DefaultExecdPort);
  50          AssertEndpointHasPort(endpoint.EndpointAddress, Constants.DefaultExecdPort);
  51  
  52          var metrics = await sandbox.GetMetricsAsync();
  53          Assert.True(metrics.CpuCount > 0);
  54          Assert.True(metrics.CpuUsedPercentage is >= 0.0 and <= 100.0);
  55          Assert.True(metrics.MemoryTotalMiB > 0);
  56          Assert.True(metrics.MemoryUsedMiB <= metrics.MemoryTotalMiB);
  57          AssertRecentTimestampMs(metrics.Timestamp, 120_000);
  58  
  59          var renewResponse = await sandbox.RenewAsync(30 * 60);
  60          Assert.NotNull(renewResponse);
  61          Assert.NotNull(renewResponse.ExpiresAt);
  62          var renewedInfo = await sandbox.GetInfoAsync();
  63          Assert.True(renewedInfo.ExpiresAt > info.ExpiresAt);
  64          Assert.True(renewResponse.ExpiresAt > info.ExpiresAt);
  65  
  66          var sandbox2 = await Sandbox.ConnectAsync(new SandboxConnectOptions
  67          {
  68              ConnectionConfig = _fixture.ConnectionConfig,
  69              SandboxId = sandbox.Id
  70          });
  71  
  72          try
  73          {
  74              Assert.Equal(sandbox.Id, sandbox2.Id);
  75              Assert.True(await sandbox2.IsHealthyAsync());
  76              var result = await sandbox2.Commands.RunAsync("echo connect-ok");
  77              Assert.Null(result.Error);
  78              Assert.Single(result.Logs.Stdout);
  79              Assert.Equal("connect-ok", result.Logs.Stdout[0].Text);
  80          }
  81          finally
  82          {
  83              await sandbox2.DisposeAsync();
  84          }
  85      }
  86  
  87      [Fact(Timeout = 2 * 60 * 1000)]
  88      public async Task Sandbox_XRequestId_Passthrough_OnServerError()
  89      {
  90          var requestId = $"e2e-csharp-server-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
  91          var missingSandboxId = $"missing-{requestId}";
  92          var baseConfig = _fixture.ConnectionConfig;
  93          var config = new ConnectionConfig(new ConnectionConfigOptions
  94          {
  95              Domain = baseConfig.Domain,
  96              Protocol = baseConfig.Protocol,
  97              ApiKey = baseConfig.ApiKey,
  98              RequestTimeoutSeconds = baseConfig.RequestTimeoutSeconds,
  99              Headers = new Dictionary<string, string> { ["X-Request-ID"] = requestId }
 100          });
 101  
 102          var ex = await Assert.ThrowsAsync<SandboxApiException>(async () =>
 103          {
 104              var connected = await Sandbox.ConnectAsync(new SandboxConnectOptions
 105              {
 106                  ConnectionConfig = config,
 107                  SandboxId = missingSandboxId
 108              });
 109              try
 110              {
 111                  await connected.GetInfoAsync();
 112              }
 113              finally
 114              {
 115                  await connected.DisposeAsync();
 116              }
 117          });
 118  
 119          Assert.Equal(requestId, ex.RequestId);
 120      }
 121  
 122      [Fact(Timeout = 2 * 60 * 1000)]
 123      public async Task Sandbox_ManualCleanup_Returns_Null_ExpiresAt()
 124      {
 125          var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
 126          {
 127              ConnectionConfig = _fixture.ConnectionConfig,
 128              Image = _fixture.DefaultImage,
 129              ManualCleanup = true,
 130              ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds,
 131              Metadata = new Dictionary<string, string> { ["tag"] = "manual-csharp-e2e-test" }
 132          });
 133  
 134          try
 135          {
 136              var info = await sandbox.GetInfoAsync();
 137              Assert.Null(info.ExpiresAt);
 138              Assert.NotNull(info.Metadata);
 139              Assert.Equal("manual-csharp-e2e-test", info.Metadata!["tag"]);
 140          }
 141          finally
 142          {
 143              await sandbox.KillAsync();
 144              await sandbox.DisposeAsync();
 145          }
 146      }
 147  
 148      [Fact(Timeout = 2 * 60 * 1000)]
 149      public async Task Sandbox_Create_With_NetworkPolicy_Get_And_Patch_Egress()
 150      {
 151          var policySandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
 152          {
 153              ConnectionConfig = _fixture.ConnectionConfig,
 154              Image = _fixture.DefaultImage,
 155              TimeoutSeconds = _fixture.DefaultTimeoutSeconds,
 156              ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds,
 157              NetworkPolicy = new NetworkPolicy
 158              {
 159                  DefaultAction = NetworkRuleAction.Deny,
 160                  Egress = new List<NetworkRule> { new() { Action = NetworkRuleAction.Allow, Target = "pypi.org" } }
 161              }
 162          });
 163  
 164          try
 165          {
 166              await Task.Delay(5000);
 167  
 168              var initialPolicy = await policySandbox.GetEgressPolicyAsync();
 169              Assert.NotNull(initialPolicy);
 170              Assert.Equal(NetworkRuleAction.Deny, initialPolicy.DefaultAction);
 171              Assert.NotNull(initialPolicy.Egress);
 172              Assert.Contains(
 173                  initialPolicy.Egress!,
 174                  rule => rule.Target == "pypi.org" && rule.Action == NetworkRuleAction.Allow);
 175  
 176              var blocked = await policySandbox.Commands.RunAsync("curl -I https://www.github.com");
 177              Assert.NotNull(blocked.Error);
 178  
 179              var allowed = await policySandbox.Commands.RunAsync("curl -I https://pypi.org");
 180              Assert.Null(allowed.Error);
 181  
 182              await policySandbox.PatchEgressRulesAsync(new List<NetworkRule>
 183              {
 184                  new() { Action = NetworkRuleAction.Allow, Target = "www.github.com" },
 185                  new() { Action = NetworkRuleAction.Deny, Target = "pypi.org" }
 186              });
 187              await Task.Delay(2000);
 188  
 189              var patchedPolicy = await policySandbox.GetEgressPolicyAsync();
 190              Assert.NotNull(patchedPolicy.Egress);
 191              Assert.Contains(
 192                  patchedPolicy.Egress!,
 193                  rule => rule.Target == "www.github.com" && rule.Action == NetworkRuleAction.Allow);
 194              Assert.Contains(
 195                  patchedPolicy.Egress!,
 196                  rule => rule.Target == "pypi.org" && rule.Action == NetworkRuleAction.Deny);
 197  
 198              var githubAllowed = await policySandbox.Commands.RunAsync("curl -I https://www.github.com");
 199              Assert.Null(githubAllowed.Error);
 200  
 201              var pypiDenied = await policySandbox.Commands.RunAsync("curl -I https://pypi.org");
 202              Assert.NotNull(pypiDenied.Error);
 203          }
 204          finally
 205          {
 206              try
 207              {
 208                  await policySandbox.KillAsync();
 209              }
 210              catch
 211              {
 212              }
 213  
 214              await policySandbox.DisposeAsync();
 215          }
 216      }
 217  
 218      [Fact(Timeout = 2 * 60 * 1000)]
 219      public async Task Sandbox_Create_With_NetworkPolicy_Get_And_Patch_Egress_Via_ServerProxy()
 220      {
 221          var policySandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
 222          {
 223              ConnectionConfig = _fixture.ServerProxyConnectionConfig,
 224              Image = _fixture.DefaultImage,
 225              TimeoutSeconds = _fixture.DefaultTimeoutSeconds,
 226              ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds,
 227              NetworkPolicy = new NetworkPolicy
 228              {
 229                  DefaultAction = NetworkRuleAction.Deny,
 230                  Egress = new List<NetworkRule> { new() { Action = NetworkRuleAction.Allow, Target = "pypi.org" } }
 231              }
 232          });
 233  
 234          try
 235          {
 236              await Task.Delay(5000);
 237  
 238              var egressEndpoint = await policySandbox.GetEndpointAsync(Constants.DefaultEgressPort);
 239              Assert.Contains(
 240                  $"/sandboxes/{policySandbox.Id}/proxy/{Constants.DefaultEgressPort}",
 241                  egressEndpoint.EndpointAddress);
 242  
 243              var initialPolicy = await policySandbox.GetEgressPolicyAsync();
 244              Assert.NotNull(initialPolicy);
 245              Assert.Equal(NetworkRuleAction.Deny, initialPolicy.DefaultAction);
 246              Assert.NotNull(initialPolicy.Egress);
 247              Assert.Contains(
 248                  initialPolicy.Egress!,
 249                  rule => rule.Target == "pypi.org" && rule.Action == NetworkRuleAction.Allow);
 250  
 251              var blocked = await policySandbox.Commands.RunAsync("curl -I https://www.github.com");
 252              Assert.NotNull(blocked.Error);
 253  
 254              var allowed = await policySandbox.Commands.RunAsync("curl -I https://pypi.org");
 255              Assert.Null(allowed.Error);
 256  
 257              await policySandbox.PatchEgressRulesAsync(new List<NetworkRule>
 258              {
 259                  new() { Action = NetworkRuleAction.Allow, Target = "www.github.com" },
 260                  new() { Action = NetworkRuleAction.Deny, Target = "pypi.org" }
 261              });
 262              await Task.Delay(2000);
 263  
 264              var patchedPolicy = await policySandbox.GetEgressPolicyAsync();
 265              Assert.NotNull(patchedPolicy.Egress);
 266              Assert.Contains(
 267                  patchedPolicy.Egress!,
 268                  rule => rule.Target == "www.github.com" && rule.Action == NetworkRuleAction.Allow);
 269              Assert.Contains(
 270                  patchedPolicy.Egress!,
 271                  rule => rule.Target == "pypi.org" && rule.Action == NetworkRuleAction.Deny);
 272          }
 273          finally
 274          {
 275              try
 276              {
 277                  await policySandbox.KillAsync();
 278              }
 279              catch
 280              {
 281              }
 282  
 283              await policySandbox.DisposeAsync();
 284          }
 285      }
 286  
 287      [Fact(Timeout = 2 * 60 * 1000)]
 288      public async Task Sandbox_Create_With_HostVolumeMount()
 289      {
 290          var hostDir = "/tmp/opensandbox-e2e/host-volume-test";
 291          var containerMountPath = "/mnt/host-data";
 292          var volumeSandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
 293          {
 294              ConnectionConfig = _fixture.ConnectionConfig,
 295              Image = _fixture.DefaultImage,
 296              TimeoutSeconds = _fixture.DefaultTimeoutSeconds,
 297              ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds,
 298              Volumes = new[]
 299              {
 300                  new Volume
 301                  {
 302                      Name = "test-host-vol",
 303                      Host = new Host { Path = hostDir },
 304                      MountPath = containerMountPath,
 305                      ReadOnly = false
 306                  }
 307              }
 308          });
 309  
 310          try
 311          {
 312              var marker = await volumeSandbox.Commands.RunAsync($"cat {containerMountPath}/marker.txt");
 313              Assert.Null(marker.Error);
 314              Assert.Single(marker.Logs.Stdout);
 315              Assert.Equal("opensandbox-e2e-marker", marker.Logs.Stdout[0].Text);
 316  
 317              var write = await volumeSandbox.Commands.RunAsync(
 318                  $"echo 'written-from-sandbox' > {containerMountPath}/sandbox-output.txt");
 319              Assert.Null(write.Error);
 320  
 321              var readBack = await volumeSandbox.Commands.RunAsync($"cat {containerMountPath}/sandbox-output.txt");
 322              Assert.Null(readBack.Error);
 323              Assert.Single(readBack.Logs.Stdout);
 324              Assert.Equal("written-from-sandbox", readBack.Logs.Stdout[0].Text);
 325          }
 326          finally
 327          {
 328              try
 329              {
 330                  await volumeSandbox.KillAsync();
 331              }
 332              catch
 333              {
 334              }
 335  
 336              await volumeSandbox.DisposeAsync();
 337          }
 338      }
 339  
 340      [Fact(Timeout = 2 * 60 * 1000)]
 341      public async Task Sandbox_Create_With_HostVolumeMount_ReadOnly()
 342      {
 343          var hostDir = "/tmp/opensandbox-e2e/host-volume-test";
 344          var containerMountPath = "/mnt/host-data-ro";
 345          var roSandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
 346          {
 347              ConnectionConfig = _fixture.ConnectionConfig,
 348              Image = _fixture.DefaultImage,
 349              TimeoutSeconds = _fixture.DefaultTimeoutSeconds,
 350              ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds,
 351              Volumes = new[]
 352              {
 353                  new Volume
 354                  {
 355                      Name = "test-host-vol-ro",
 356                      Host = new Host { Path = hostDir },
 357                      MountPath = containerMountPath,
 358                      ReadOnly = true
 359                  }
 360              }
 361          });
 362  
 363          try
 364          {
 365              var marker = await roSandbox.Commands.RunAsync($"cat {containerMountPath}/marker.txt");
 366              Assert.Null(marker.Error);
 367              Assert.Single(marker.Logs.Stdout);
 368              Assert.Equal("opensandbox-e2e-marker", marker.Logs.Stdout[0].Text);
 369  
 370              var write = await roSandbox.Commands.RunAsync($"touch {containerMountPath}/should-fail.txt");
 371              var stat = await roSandbox.Commands.RunAsync(
 372                  $"test ! -e {containerMountPath}/should-fail.txt && echo OK");
 373              var writeWasRejected = write.Error is not null || write.Logs.Stderr.Count > 0;
 374              var fileWasNotCreated =
 375                  stat.Error is null &&
 376                  stat.Logs.Stdout.Count == 1 &&
 377                  stat.Logs.Stdout[0].Text == "OK";
 378              Assert.True(
 379                  writeWasRejected || fileWasNotCreated,
 380                  "Write on read-only host volume should fail or leave no created file.");
 381          }
 382          finally
 383          {
 384              try
 385              {
 386                  await roSandbox.KillAsync();
 387              }
 388              catch
 389              {
 390              }
 391  
 392              await roSandbox.DisposeAsync();
 393          }
 394      }
 395  
 396      [Fact(Timeout = 2 * 60 * 1000)]
 397      public async Task Sandbox_Create_With_PvcVolumeMount()
 398      {
 399          var pvcVolumeName = "opensandbox-e2e-pvc-test";
 400          var containerMountPath = "/mnt/pvc-data";
 401          var pvcSandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
 402          {
 403              ConnectionConfig = _fixture.ConnectionConfig,
 404              Image = _fixture.DefaultImage,
 405              TimeoutSeconds = _fixture.DefaultTimeoutSeconds,
 406              ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds,
 407              Volumes = new[]
 408              {
 409                  new Volume
 410                  {
 411                      Name = "test-pvc-vol",
 412                      Pvc = new PVC { ClaimName = pvcVolumeName },
 413                      MountPath = containerMountPath,
 414                      ReadOnly = false
 415                  }
 416              }
 417          });
 418  
 419          try
 420          {
 421              var marker = await pvcSandbox.Commands.RunAsync($"cat {containerMountPath}/marker.txt");
 422              Assert.Null(marker.Error);
 423              Assert.Single(marker.Logs.Stdout);
 424              Assert.Equal("pvc-marker-data", marker.Logs.Stdout[0].Text);
 425  
 426              var write = await pvcSandbox.Commands.RunAsync(
 427                  $"echo 'written-to-pvc' > {containerMountPath}/pvc-output.txt");
 428              Assert.Null(write.Error);
 429  
 430              var readBack = await pvcSandbox.Commands.RunAsync($"cat {containerMountPath}/pvc-output.txt");
 431              Assert.Null(readBack.Error);
 432              Assert.Single(readBack.Logs.Stdout);
 433              Assert.Equal("written-to-pvc", readBack.Logs.Stdout[0].Text);
 434          }
 435          finally
 436          {
 437              try
 438              {
 439                  await pvcSandbox.KillAsync();
 440              }
 441              catch
 442              {
 443              }
 444  
 445              await pvcSandbox.DisposeAsync();
 446          }
 447      }
 448  
 449      [Fact(Timeout = 2 * 60 * 1000)]
 450      public async Task Sandbox_Create_With_PvcVolumeMount_ReadOnly()
 451      {
 452          var pvcVolumeName = "opensandbox-e2e-pvc-test";
 453          var containerMountPath = "/mnt/pvc-data-ro";
 454          var roSandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
 455          {
 456              ConnectionConfig = _fixture.ConnectionConfig,
 457              Image = _fixture.DefaultImage,
 458              TimeoutSeconds = _fixture.DefaultTimeoutSeconds,
 459              ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds,
 460              Volumes = new[]
 461              {
 462                  new Volume
 463                  {
 464                      Name = "test-pvc-vol-ro",
 465                      Pvc = new PVC { ClaimName = pvcVolumeName },
 466                      MountPath = containerMountPath,
 467                      ReadOnly = true
 468                  }
 469              }
 470          });
 471  
 472          try
 473          {
 474              var marker = await roSandbox.Commands.RunAsync($"cat {containerMountPath}/marker.txt");
 475              Assert.Null(marker.Error);
 476              Assert.Single(marker.Logs.Stdout);
 477              Assert.Equal("pvc-marker-data", marker.Logs.Stdout[0].Text);
 478  
 479              var write = await roSandbox.Commands.RunAsync($"touch {containerMountPath}/should-fail.txt");
 480              var stat = await roSandbox.Commands.RunAsync(
 481                  $"test ! -e {containerMountPath}/should-fail.txt && echo OK");
 482              var writeWasRejected = write.Error is not null || write.Logs.Stderr.Count > 0;
 483              var fileWasNotCreated =
 484                  stat.Error is null &&
 485                  stat.Logs.Stdout.Count == 1 &&
 486                  stat.Logs.Stdout[0].Text == "OK";
 487              Assert.True(
 488                  writeWasRejected || fileWasNotCreated,
 489                  "Write on read-only PVC volume should fail or leave no created file.");
 490          }
 491          finally
 492          {
 493              try
 494              {
 495                  await roSandbox.KillAsync();
 496              }
 497              catch
 498              {
 499              }
 500  
 501              await roSandbox.DisposeAsync();
 502          }
 503      }
 504  
 505      [Fact(Timeout = 2 * 60 * 1000)]
 506      public async Task Sandbox_Create_With_PvcVolumeMount_SubPath()
 507      {
 508          var pvcVolumeName = "opensandbox-e2e-pvc-test";
 509          var containerMountPath = "/mnt/train";
 510          var subPathSandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
 511          {
 512              ConnectionConfig = _fixture.ConnectionConfig,
 513              Image = _fixture.DefaultImage,
 514              TimeoutSeconds = _fixture.DefaultTimeoutSeconds,
 515              ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds,
 516              Volumes = new[]
 517              {
 518                  new Volume
 519                  {
 520                      Name = "test-pvc-subpath",
 521                      Pvc = new PVC { ClaimName = pvcVolumeName },
 522                      MountPath = containerMountPath,
 523                      ReadOnly = false,
 524                      SubPath = "datasets/train"
 525                  }
 526              }
 527          });
 528  
 529          try
 530          {
 531              var marker = await subPathSandbox.Commands.RunAsync($"cat {containerMountPath}/marker.txt");
 532              Assert.Null(marker.Error);
 533              Assert.Single(marker.Logs.Stdout);
 534              Assert.Equal("pvc-subpath-marker", marker.Logs.Stdout[0].Text);
 535  
 536              var ls = await subPathSandbox.Commands.RunAsync($"ls {containerMountPath}/");
 537              Assert.Null(ls.Error);
 538              var lsText = string.Join("\n", ls.Logs.Stdout.Select(x => x.Text));
 539              Assert.Contains("marker.txt", lsText, StringComparison.Ordinal);
 540              Assert.DoesNotContain("datasets", lsText, StringComparison.Ordinal);
 541  
 542              var write = await subPathSandbox.Commands.RunAsync(
 543                  $"echo 'subpath-write-test' > {containerMountPath}/output.txt");
 544              Assert.Null(write.Error);
 545  
 546              var readBack = await subPathSandbox.Commands.RunAsync($"cat {containerMountPath}/output.txt");
 547              Assert.Null(readBack.Error);
 548              Assert.Single(readBack.Logs.Stdout);
 549              Assert.Equal("subpath-write-test", readBack.Logs.Stdout[0].Text);
 550          }
 551          finally
 552          {
 553              try
 554              {
 555                  await subPathSandbox.KillAsync();
 556              }
 557              catch
 558              {
 559              }
 560  
 561              await subPathSandbox.DisposeAsync();
 562          }
 563      }
 564  
 565      [Fact(Timeout = 2 * 60 * 1000)]
 566      public async Task Command_Execution_Success_WorkingDirectory_Background_Failure()
 567      {
 568          var sandbox = _fixture.Sandbox;
 569  
 570          var stdoutMessages = new ConcurrentBag<OutputMessage>();
 571          var stderrMessages = new ConcurrentBag<OutputMessage>();
 572          var results = new ConcurrentBag<ExecutionResult>();
 573          var errors = new ConcurrentBag<ExecutionError>();
 574          var completedEvents = new ConcurrentBag<ExecutionComplete>();
 575          var initEvents = new ConcurrentBag<ExecutionInit>();
 576  
 577          var handlers = new ExecutionHandlers
 578          {
 579              OnStdout = msg => { stdoutMessages.Add(msg); return Task.CompletedTask; },
 580              OnStderr = msg => { stderrMessages.Add(msg); return Task.CompletedTask; },
 581              OnResult = res => { results.Add(res); return Task.CompletedTask; },
 582              OnExecutionComplete = complete => { completedEvents.Add(complete); return Task.CompletedTask; },
 583              OnError = err => { errors.Add(err); return Task.CompletedTask; },
 584              OnInit = init => { initEvents.Add(init); return Task.CompletedTask; }
 585          };
 586  
 587          var echoResult = await sandbox.Commands.RunAsync("echo Hello OpenSandbox E2E", handlers: handlers);
 588          Assert.False(string.IsNullOrWhiteSpace(echoResult.Id));
 589          Assert.Null(echoResult.Error);
 590          Assert.Single(echoResult.Logs.Stdout);
 591          Assert.Equal("Hello OpenSandbox E2E", echoResult.Logs.Stdout[0].Text);
 592          AssertRecentTimestampMs(echoResult.Logs.Stdout[0].Timestamp, 60_000);
 593          Assert.Equal(0, echoResult.ExitCode);
 594          Assert.NotNull(echoResult.Complete);
 595          Assert.True(echoResult.Complete!.ExecutionTimeMs >= 0);
 596          AssertTerminalEventContract(initEvents, completedEvents, errors, echoResult.Id!);
 597  
 598          var pwdResult = await sandbox.Commands.RunAsync(
 599              "pwd",
 600              options: new RunCommandOptions { WorkingDirectory = "/tmp" });
 601          Assert.Null(pwdResult.Error);
 602          Assert.Single(pwdResult.Logs.Stdout);
 603          Assert.Equal("/tmp", pwdResult.Logs.Stdout[0].Text);
 604          Assert.Equal(0, pwdResult.ExitCode);
 605          Assert.NotNull(pwdResult.Complete);
 606  
 607          var start = DateTime.UtcNow;
 608          var backgroundResult = await sandbox.Commands.RunAsync(
 609              "sleep 30",
 610              options: new RunCommandOptions { Background = true });
 611          var elapsed = DateTime.UtcNow - start;
 612          Assert.True(elapsed.TotalSeconds < 10, "Background command should return quickly.");
 613          Assert.Null(backgroundResult.ExitCode);
 614  
 615          stdoutMessages = new ConcurrentBag<OutputMessage>();
 616          stderrMessages = new ConcurrentBag<OutputMessage>();
 617          errors = new ConcurrentBag<ExecutionError>();
 618          completedEvents = new ConcurrentBag<ExecutionComplete>();
 619          initEvents = new ConcurrentBag<ExecutionInit>();
 620  
 621          var failResult = await sandbox.Commands.RunAsync(
 622              "nonexistent-command-that-does-not-exist",
 623              handlers: new ExecutionHandlers
 624              {
 625                  OnStdout = msg => { stdoutMessages.Add(msg); return Task.CompletedTask; },
 626                  OnStderr = msg => { stderrMessages.Add(msg); return Task.CompletedTask; },
 627                  OnError = err => { errors.Add(err); return Task.CompletedTask; },
 628                  OnExecutionComplete = complete => { completedEvents.Add(complete); return Task.CompletedTask; },
 629                  OnInit = init => { initEvents.Add(init); return Task.CompletedTask; }
 630              });
 631  
 632          Assert.NotNull(failResult.Error);
 633          Assert.Equal("CommandExecError", failResult.Error!.Name);
 634          Assert.True(failResult.Logs.Stderr.Count > 0);
 635          Assert.Contains(
 636              failResult.Logs.Stderr,
 637              msg => msg.Text.Contains("nonexistent-command-that-does-not-exist", StringComparison.Ordinal));
 638          Assert.Null(failResult.Complete);
 639          Assert.NotNull(failResult.ExitCode);
 640          Assert.Equal(int.Parse(failResult.Error.Value), failResult.ExitCode);
 641          AssertTerminalEventContract(initEvents, completedEvents, errors, failResult.Id!);
 642          Assert.Empty(completedEvents);
 643      }
 644  
 645      [Fact(Timeout = 2 * 60 * 1000)]
 646      public async Task Command_Status_And_Background_Logs()
 647      {
 648          var sandbox = _fixture.Sandbox;
 649  
 650          var execResult = await sandbox.Commands.RunAsync(
 651              "sh -c 'echo log-line-1; echo log-line-2; sleep 2'",
 652              options: new RunCommandOptions { Background = true });
 653          Assert.False(string.IsNullOrWhiteSpace(execResult.Id));
 654          var commandId = execResult.Id!;
 655  
 656          var status = await sandbox.Commands.GetCommandStatusAsync(commandId);
 657          Assert.Equal(commandId, status.Id);
 658          Assert.NotNull(status.Running);
 659  
 660          var logsText = new StringBuilder();
 661          long? cursor = null;
 662          for (var i = 0; i < 20; i++)
 663          {
 664              var logs = await sandbox.Commands.GetBackgroundCommandLogsAsync(commandId, cursor);
 665              logsText.Append(logs.Content);
 666              cursor = logs.Cursor ?? cursor;
 667              if (logsText.ToString().Contains("log-line-2", StringComparison.Ordinal))
 668              {
 669                  break;
 670              }
 671  
 672              await Task.Delay(1000);
 673          }
 674  
 675          var finalLogs = logsText.ToString();
 676          Assert.Contains("log-line-1", finalLogs, StringComparison.Ordinal);
 677          Assert.Contains("log-line-2", finalLogs, StringComparison.Ordinal);
 678      }
 679  
 680      [Fact(Timeout = 2 * 60 * 1000)]
 681      public async Task Command_Env_Injection()
 682      {
 683          var sandbox = _fixture.Sandbox;
 684          var envKey = "OPEN_SANDBOX_E2E_CMD_ENV";
 685          var envValue = $"env-ok-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
 686          var probeCommand =
 687              $"sh -c 'if [ -z \"${{{envKey}:-}}\" ]; then echo \"__EMPTY__\"; else echo \"${{{envKey}}}\"; fi'";
 688  
 689          var baseline = await sandbox.Commands.RunAsync(probeCommand);
 690          Assert.Null(baseline.Error);
 691          var baselineOutput = string.Join("\n", baseline.Logs.Stdout.Select(m => m.Text)).Trim();
 692          Assert.Equal("__EMPTY__", baselineOutput);
 693  
 694          var injected = await sandbox.Commands.RunAsync(
 695              probeCommand,
 696              options: new RunCommandOptions
 697              {
 698                  Envs = new Dictionary<string, string>
 699                  {
 700                      [envKey] = envValue,
 701                      ["OPEN_SANDBOX_E2E_SECOND_ENV"] = "second-ok"
 702                  }
 703              });
 704          Assert.Null(injected.Error);
 705          var injectedOutput = string.Join("\n", injected.Logs.Stdout.Select(m => m.Text)).Trim();
 706          Assert.Equal(envValue, injectedOutput);
 707      }
 708  
 709      [Fact(Timeout = 2 * 60 * 1000)]
 710      public async Task Bash_Session_API_WorkingDirectory_And_Env_Persistence()
 711      {
 712          var sandbox = _fixture.Sandbox;
 713  
 714          var sid = await sandbox.Commands.CreateSessionAsync(new CreateSessionOptions { WorkingDirectory = "/tmp" });
 715          Assert.False(string.IsNullOrWhiteSpace(sid));
 716  
 717          var run = await sandbox.Commands.RunInSessionAsync(sid, "pwd");
 718          Assert.Null(run.Error);
 719          Assert.Equal(0, run.ExitCode);
 720          var stdout = string.Join("", run.Logs.Stdout.Select(m => m.Text)).Trim();
 721          Assert.Equal("/tmp", stdout);
 722  
 723          run = await sandbox.Commands.RunInSessionAsync(
 724              sid,
 725              "pwd",
 726              options: new RunInSessionOptions { WorkingDirectory = "/var" });
 727          Assert.Null(run.Error);
 728          Assert.Equal(0, run.ExitCode);
 729          stdout = string.Join("", run.Logs.Stdout.Select(m => m.Text)).Trim();
 730          Assert.Equal("/var", stdout);
 731  
 732          run = await sandbox.Commands.RunInSessionAsync(
 733              sid,
 734              "pwd",
 735              options: new RunInSessionOptions { WorkingDirectory = "/tmp" });
 736          Assert.Null(run.Error);
 737          Assert.Equal(0, run.ExitCode);
 738          stdout = string.Join("", run.Logs.Stdout.Select(m => m.Text)).Trim();
 739          Assert.Equal("/tmp", stdout);
 740  
 741          run = await sandbox.Commands.RunInSessionAsync(sid, "export E2E_SESSION_ENV=session-env-ok");
 742          Assert.Null(run.Error);
 743  
 744          run = await sandbox.Commands.RunInSessionAsync(sid, "echo $E2E_SESSION_ENV");
 745          Assert.Null(run.Error);
 746          Assert.Equal(0, run.ExitCode);
 747          stdout = string.Join("", run.Logs.Stdout.Select(m => m.Text)).Trim();
 748          Assert.Equal("session-env-ok", stdout);
 749  
 750          run = await sandbox.Commands.RunInSessionAsync(sid, "sh -c 'echo session-fail >&2; exit 7'");
 751          Assert.NotNull(run.Error);
 752          Assert.Equal("CommandExecError", run.Error!.Name);
 753          Assert.Equal("7", run.Error.Value);
 754          Assert.Equal(7, run.ExitCode);
 755          Assert.Null(run.Complete);
 756  
 757          var sid2 = await sandbox.Commands.CreateSessionAsync(new CreateSessionOptions { WorkingDirectory = "/var" });
 758          Assert.False(string.IsNullOrWhiteSpace(sid2));
 759          run = await sandbox.Commands.RunInSessionAsync(sid2, "pwd");
 760          Assert.Null(run.Error);
 761          Assert.Equal(0, run.ExitCode);
 762          stdout = string.Join("", run.Logs.Stdout.Select(m => m.Text)).Trim();
 763          Assert.Equal("/var", stdout);
 764  
 765          await sandbox.Commands.DeleteSessionAsync(sid);
 766          await sandbox.Commands.DeleteSessionAsync(sid2);
 767      }
 768  
 769      [Fact(Timeout = 2 * 60 * 1000)]
 770      public async Task Filesystem_Operations_CRUD_Replace_Move_Delete()
 771      {
 772          var sandbox = _fixture.Sandbox;
 773  
 774          var testDir1 = $"/tmp/fs_test1_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
 775          var testDir2 = $"/tmp/fs_test2_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
 776  
 777          await sandbox.Files.CreateDirectoriesAsync(new[]
 778          {
 779              new CreateDirectoryEntry { Path = testDir1, Mode = 755 },
 780              new CreateDirectoryEntry { Path = testDir2, Mode = 644 }
 781          });
 782  
 783          var dirInfo = await sandbox.Files.GetFileInfoAsync(new[] { testDir1, testDir2 });
 784          Assert.Equal(testDir1, dirInfo[testDir1].Path);
 785          Assert.Equal(755, dirInfo[testDir1].Mode);
 786          AssertTimesClose(dirInfo[testDir1].CreatedAt, dirInfo[testDir1].ModifiedAt, 2);
 787  
 788          var testFile1 = $"{testDir1}/test_file1.txt";
 789          var testFile2 = $"{testDir1}/test_file2.txt";
 790          var testFile3 = $"{testDir1}/test_file3.txt";
 791          var testContent = "Hello Filesystem! Line 2. Line 3.";
 792  
 793          await sandbox.Files.WriteFilesAsync(new[]
 794          {
 795              new WriteEntry { Path = testFile1, Data = testContent, Mode = 644 },
 796              new WriteEntry { Path = testFile2, Data = Encoding.UTF8.GetBytes(testContent), Mode = 755 },
 797              new WriteEntry { Path = testFile3, Data = new MemoryStream(Encoding.UTF8.GetBytes(testContent)), Mode = 755 }
 798          });
 799  
 800          var readContent1 = await sandbox.Files.ReadFileAsync(
 801              testFile1,
 802              new ReadFileOptions { Encoding = "utf-8" });
 803          var readContent1Partial = await sandbox.Files.ReadFileAsync(
 804              testFile1,
 805              new ReadFileOptions { Encoding = "utf-8", Range = "bytes=0-9" });
 806          var readBytes2 = await sandbox.Files.ReadBytesAsync(testFile2);
 807          var readContent2 = Encoding.UTF8.GetString(readBytes2);
 808  
 809          var chunks = new List<byte>();
 810          await foreach (var chunk in sandbox.Files.ReadBytesStreamAsync(testFile3))
 811          {
 812              chunks.AddRange(chunk);
 813          }
 814  
 815          var readContent3 = Encoding.UTF8.GetString(chunks.ToArray());
 816  
 817          Assert.Equal(testContent, readContent1);
 818          Assert.Equal(testContent, readContent2);
 819          Assert.Equal(testContent, readContent3);
 820          Assert.Equal(testContent.Substring(0, 10), readContent1Partial);
 821  
 822          var fileInfoMap = await sandbox.Files.GetFileInfoAsync(new[] { testFile1, testFile2, testFile3 });
 823          var expectedSize = Encoding.UTF8.GetBytes(testContent).Length;
 824          Assert.Equal(expectedSize, fileInfoMap[testFile1].Size);
 825          Assert.Equal(expectedSize, fileInfoMap[testFile2].Size);
 826          Assert.Equal(expectedSize, fileInfoMap[testFile3].Size);
 827          AssertTimesClose(fileInfoMap[testFile1].CreatedAt, fileInfoMap[testFile1].ModifiedAt, 2);
 828  
 829          var found = new HashSet<string>();
 830          var searchResults = await sandbox.Files.SearchAsync(new SearchEntry { Path = testDir1, Pattern = "*" });
 831          foreach (var entry in searchResults)
 832          {
 833              found.Add(entry.Path);
 834          }
 835          Assert.Equal(new HashSet<string> { testFile1, testFile2, testFile3 }, found);
 836  
 837          await sandbox.Files.SetPermissionsAsync(new[]
 838          {
 839              new SetPermissionEntry { Path = testFile1, Mode = 755 },
 840              new SetPermissionEntry { Path = testFile2, Mode = 600 }
 841          });
 842  
 843          var updatedInfo = await sandbox.Files.GetFileInfoAsync(new[] { testFile1, testFile2 });
 844          Assert.Equal(755, updatedInfo[testFile1].Mode);
 845          Assert.Equal(600, updatedInfo[testFile2].Mode);
 846  
 847          var beforeUpdate = (await sandbox.Files.GetFileInfoAsync(new[] { testFile1 }))[testFile1];
 848          var updatedContent1 = testContent + " Appended line.";
 849          await Task.Delay(50);
 850          await sandbox.Files.WriteFilesAsync(new[]
 851          {
 852              new WriteEntry { Path = testFile1, Data = updatedContent1, Mode = 644 }
 853          });
 854  
 855          var newContent1 = await sandbox.Files.ReadFileAsync(testFile1, new ReadFileOptions { Encoding = "utf-8" });
 856          Assert.Equal(updatedContent1, newContent1);
 857          var afterUpdate = (await sandbox.Files.GetFileInfoAsync(new[] { testFile1 }))[testFile1];
 858          AssertModifiedUpdated(beforeUpdate.ModifiedAt, afterUpdate.ModifiedAt, 1, 1000);
 859  
 860          await Task.Delay(50);
 861          await sandbox.Files.ReplaceContentsAsync(new[]
 862          {
 863              new ContentReplaceEntry
 864              {
 865                  Path = testFile1,
 866                  OldContent = "Appended line.",
 867                  NewContent = "Replaced line."
 868              }
 869          });
 870  
 871          var replaced = await sandbox.Files.ReadFileAsync(testFile1, new ReadFileOptions { Encoding = "utf-8" });
 872          Assert.Contains("Replaced line.", replaced, StringComparison.Ordinal);
 873          Assert.DoesNotContain("Appended line.", replaced, StringComparison.Ordinal);
 874  
 875          var movedPath = $"{testDir2}/moved_file3.txt";
 876          await sandbox.Files.MoveFilesAsync(new[] { new MoveEntry { Src = testFile3, Dest = movedPath } });
 877          var movedBytes = await sandbox.Files.ReadBytesAsync(movedPath);
 878          Assert.Equal(testContent, Encoding.UTF8.GetString(movedBytes));
 879          await Assert.ThrowsAnyAsync<Exception>(() => sandbox.Files.ReadBytesAsync(testFile3));
 880  
 881          await sandbox.Files.DeleteFilesAsync(new[] { testFile2 });
 882          await Assert.ThrowsAnyAsync<Exception>(() => sandbox.Files.ReadFileAsync(testFile2));
 883  
 884          await sandbox.Files.DeleteDirectoriesAsync(new[] { testDir1, testDir2 });
 885          var verify = await sandbox.Commands.RunAsync(
 886              $"test ! -d {testDir1} && test ! -d {testDir2} && echo OK",
 887              options: new RunCommandOptions { WorkingDirectory = "/tmp" });
 888          for (var attempt = 0; attempt < 3; attempt++)
 889          {
 890              var verified =
 891                  verify.Error is null &&
 892                  verify.Logs.Stdout.Count == 1 &&
 893                  verify.Logs.Stdout[0].Text == "OK";
 894              if (verified)
 895              {
 896                  break;
 897              }
 898  
 899              await Task.Delay(1000);
 900              verify = await sandbox.Commands.RunAsync(
 901                  $"test ! -d {testDir1} && test ! -d {testDir2} && echo OK",
 902                  options: new RunCommandOptions { WorkingDirectory = "/tmp" });
 903          }
 904          Assert.Null(verify.Error);
 905          Assert.Single(verify.Logs.Stdout);
 906          Assert.Equal("OK", verify.Logs.Stdout[0].Text);
 907      }
 908  
 909      [Fact(Timeout = 2 * 60 * 1000)]
 910      public async Task Command_Interrupt()
 911      {
 912          var sandbox = _fixture.Sandbox;
 913  
 914          var initEvents = new ConcurrentBag<ExecutionInit>();
 915          var completedEvents = new ConcurrentBag<ExecutionComplete>();
 916          var errors = new ConcurrentBag<ExecutionError>();
 917          var initLatch = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
 918  
 919          var handlers = new ExecutionHandlers
 920          {
 921              OnInit = init =>
 922              {
 923                  initEvents.Add(init);
 924                  initLatch.TrySetResult(init.Id);
 925                  return Task.CompletedTask;
 926              },
 927              OnExecutionComplete = complete => { completedEvents.Add(complete); return Task.CompletedTask; },
 928              OnError = err => { errors.Add(err); return Task.CompletedTask; }
 929          };
 930  
 931          var executionTask = sandbox.Commands.RunAsync("sleep 30", handlers: handlers);
 932          var id = await initLatch.Task.WaitAsync(TimeSpan.FromSeconds(15));
 933  
 934          await Task.Delay(2000);
 935          await sandbox.Commands.InterruptAsync(id);
 936  
 937          var result = await executionTask.WaitAsync(TimeSpan.FromSeconds(30));
 938          Assert.Equal(id, result.Id);
 939          Assert.True((completedEvents.Count > 0) ^ (errors.Count > 0));
 940          Assert.True(result.Error != null || result.Logs.Stderr.Count > 0);
 941      }
 942  
 943      [Fact(Timeout = 5 * 60 * 1000)]
 944      public async Task Sandbox_Pause_And_Resume()
 945      {
 946          var sandbox = _fixture.Sandbox;
 947  
 948          await Task.Delay(5000);
 949          await sandbox.PauseAsync();
 950  
 951          var pausedInfo = await WaitForStateAsync(sandbox, SandboxStates.Paused, TimeSpan.FromMinutes(5));
 952          Assert.Equal(SandboxStates.Paused, pausedInfo.Status.State);
 953  
 954          var healthy = true;
 955          for (var i = 0; i < 10; i++)
 956          {
 957              healthy = await sandbox.IsHealthyAsync();
 958              if (!healthy)
 959              {
 960                  break;
 961              }
 962              await Task.Delay(500);
 963          }
 964          Assert.False(healthy, "Sandbox should be unhealthy after pause.");
 965  
 966          var resumed = await sandbox.ResumeAsync(new SandboxResumeOptions
 967          {
 968              ReadyTimeoutSeconds = 60,
 969              HealthCheckPollingInterval = 1000
 970          });
 971  
 972          var resumedInfo = await WaitForStateAsync(resumed, SandboxStates.Running, TimeSpan.FromMinutes(3));
 973          Assert.Equal(SandboxStates.Running, resumedInfo.Status.State);
 974  
 975          var isHealthy = false;
 976          for (var i = 0; i < 30; i++)
 977          {
 978              isHealthy = await resumed.IsHealthyAsync();
 979              if (isHealthy)
 980              {
 981                  break;
 982              }
 983              await Task.Delay(1000);
 984          }
 985          Assert.True(isHealthy, "Sandbox should be healthy after resume.");
 986  
 987          // Smoke-check command path after resume to ensure execd adapter is usable.
 988          var echo = await resumed.Commands.RunAsync("echo resume-ok");
 989          Assert.Null(echo.Error);
 990          Assert.Single(echo.Logs.Stdout);
 991          Assert.Equal("resume-ok", echo.Logs.Stdout[0].Text);
 992      }
 993  
 994      private static void AssertRecentTimestampMs(long ts, long toleranceMs)
 995      {
 996          Assert.True(ts > 0);
 997          var delta = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - ts);
 998          Assert.True(delta <= toleranceMs, $"timestamp too far from now: delta={delta}ms (ts={ts})");
 999      }
1000  
1001      private static void AssertEndpointHasPort(string endpoint, int expectedPort)
1002      {
1003          Assert.False(endpoint.Contains("://", StringComparison.Ordinal), $"unexpected scheme in endpoint: {endpoint}");
1004          if (endpoint.Contains('/'))
1005          {
1006              Assert.EndsWith($"/{expectedPort}", endpoint, StringComparison.Ordinal);
1007              Assert.False(string.IsNullOrWhiteSpace(endpoint.Split('/', 2)[0]));
1008              return;
1009          }
1010  
1011          var parts = endpoint.Split(':');
1012          Assert.True(parts.Length >= 2, $"missing host:port in endpoint: {endpoint}");
1013          var port = parts[^1];
1014          Assert.True(int.TryParse(port, out var parsed));
1015          Assert.Equal(expectedPort, parsed);
1016      }
1017  
1018      private static void AssertTimesClose(DateTime? createdAt, DateTime? modifiedAt, double toleranceSeconds)
1019      {
1020          Assert.NotNull(createdAt);
1021          Assert.NotNull(modifiedAt);
1022          var delta = Math.Abs((modifiedAt!.Value - createdAt!.Value).TotalSeconds);
1023          Assert.True(delta <= toleranceSeconds, $"created/modified skew too large: {delta}s");
1024      }
1025  
1026      private static void AssertModifiedUpdated(DateTime? before, DateTime? after, int minDeltaMs, int allowSkewMs)
1027      {
1028          Assert.NotNull(before);
1029          Assert.NotNull(after);
1030          var deltaMs = (after!.Value - before!.Value).TotalMilliseconds;
1031          Assert.True(deltaMs >= minDeltaMs - allowSkewMs, $"modified_at did not update as expected: delta_ms={deltaMs}");
1032      }
1033  
1034      private static void AssertTerminalEventContract(
1035          IEnumerable<ExecutionInit> initEvents,
1036          IEnumerable<ExecutionComplete> completedEvents,
1037          IEnumerable<ExecutionError> errors,
1038          string executionId)
1039      {
1040          var initList = initEvents.ToList();
1041          var completeList = completedEvents.ToList();
1042          var errorList = errors.ToList();
1043  
1044          Assert.Single(initList);
1045          Assert.False(string.IsNullOrWhiteSpace(initList[0].Id));
1046          Assert.Equal(executionId, initList[0].Id);
1047          AssertRecentTimestampMs(initList[0].Timestamp, 120_000);
1048  
1049          var hasComplete = completeList.Count > 0;
1050          var hasError = errorList.Count > 0;
1051          Assert.True(hasComplete || hasError);
1052  
1053          if (hasComplete)
1054          {
1055              Assert.Single(completeList);
1056              AssertRecentTimestampMs(completeList[0].Timestamp, 180_000);
1057              Assert.True(completeList[0].ExecutionTimeMs >= 0);
1058          }
1059  
1060          if (hasError)
1061          {
1062              Assert.False(string.IsNullOrWhiteSpace(errorList[0].Name));
1063              Assert.False(string.IsNullOrWhiteSpace(errorList[0].Value));
1064              AssertRecentTimestampMs(errorList[0].Timestamp, 180_000);
1065          }
1066      }
1067  
1068      private static async Task<SandboxInfo> WaitForStateAsync(
1069          Sandbox sandbox,
1070          string expectedState,
1071          TimeSpan timeout)
1072      {
1073          var deadline = DateTime.UtcNow + timeout;
1074          SandboxInfo info;
1075          while (true)
1076          {
1077              info = await sandbox.GetInfoAsync();
1078              if (info.Status.State == expectedState)
1079              {
1080                  return info;
1081              }
1082  
1083              if (DateTime.UtcNow > deadline)
1084              {
1085                  throw new TimeoutException($"Timed out waiting for state={expectedState}, last_state={info.Status.State}");
1086              }
1087  
1088              await Task.Delay(1000);
1089          }
1090      }
1091  }
1092  
1093  public sealed class SandboxE2ETestFixture : IAsyncLifetime
1094  {
1095      private readonly E2ETestFixture _baseFixture = new();
1096      private Sandbox? _sandbox;
1097  
1098      public ConnectionConfig ConnectionConfig => _baseFixture.ConnectionConfig;
1099      public ConnectionConfig ServerProxyConnectionConfig => _baseFixture.ServerProxyConnectionConfig;
1100      public string DefaultImage => _baseFixture.DefaultImage;
1101      public int DefaultTimeoutSeconds => _baseFixture.DefaultTimeoutSeconds;
1102      public int DefaultReadyTimeoutSeconds => _baseFixture.DefaultReadyTimeoutSeconds;
1103      public Sandbox Sandbox => _sandbox ?? throw new InvalidOperationException("Sandbox is not initialized.");
1104  
1105      public async Task InitializeAsync()
1106      {
1107          _sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
1108          {
1109              ConnectionConfig = _baseFixture.ConnectionConfig,
1110              Image = _baseFixture.DefaultImage,
1111              TimeoutSeconds = _baseFixture.DefaultTimeoutSeconds,
1112              ReadyTimeoutSeconds = _baseFixture.DefaultReadyTimeoutSeconds,
1113              Metadata = new Dictionary<string, string> { ["tag"] = "csharp-e2e-test" },
1114              Env = new Dictionary<string, string> { ["E2E_TEST"] = "true" },
1115              HealthCheckPollingInterval = 500
1116          });
1117      }
1118  
1119      public async Task DisposeAsync()
1120      {
1121          if (_sandbox == null)
1122          {
1123              return;
1124          }
1125  
1126          try
1127          {
1128              await _sandbox.KillAsync();
1129          }
1130          catch
1131          {
1132          }
1133  
1134          await _sandbox.DisposeAsync();
1135      }
1136  }