e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
472 lines
17 KiB
C#
472 lines
17 KiB
C#
// Copyright 2026 Alibaba Group Holding Ltd.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using FluentAssertions;
|
|
using OpenSandbox.Adapters;
|
|
using OpenSandbox.Core;
|
|
using OpenSandbox.Internal;
|
|
using OpenSandbox.Models;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Xunit;
|
|
|
|
namespace OpenSandbox.Tests;
|
|
|
|
public class CommandsAdapterTests
|
|
{
|
|
[Fact]
|
|
public async Task GetCommandStatusAsync_ShouldParseStatusResponse()
|
|
{
|
|
var httpHandler = new StubHttpMessageHandler((request, _) =>
|
|
{
|
|
var body = "{\"id\":\"cmd-1\",\"content\":\"sleep 1\",\"running\":true,\"exit_code\":null}";
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
|
});
|
|
});
|
|
var adapter = CreateAdapter(httpHandler);
|
|
|
|
var status = await adapter.GetCommandStatusAsync("cmd-1");
|
|
|
|
status.Id.Should().Be("cmd-1");
|
|
status.Content.Should().Be("sleep 1");
|
|
status.Running.Should().BeTrue();
|
|
status.ExitCode.Should().BeNull();
|
|
httpHandler.RequestUris.Should().Contain(uri => uri.EndsWith("/command/status/cmd-1"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetBackgroundCommandLogsAsync_ShouldParseCursorHeader()
|
|
{
|
|
var httpHandler = new StubHttpMessageHandler((request, _) =>
|
|
{
|
|
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent("line1\nline2\n", Encoding.UTF8, "text/plain")
|
|
};
|
|
response.Headers.Add("EXECD-COMMANDS-TAIL-CURSOR", "42");
|
|
return Task.FromResult(response);
|
|
});
|
|
var adapter = CreateAdapter(httpHandler);
|
|
|
|
var logs = await adapter.GetBackgroundCommandLogsAsync("cmd-2", cursor: 10);
|
|
|
|
logs.Content.Should().Contain("line1");
|
|
logs.Cursor.Should().Be(42);
|
|
httpHandler.RequestUris.Should().Contain(uri => uri.Contains("/command/cmd-2/logs?cursor=10"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetBackgroundCommandLogsAsync_ShouldReturnNullCursorWhenHeaderMissing()
|
|
{
|
|
var httpHandler = new StubHttpMessageHandler((request, _) =>
|
|
{
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent("only-content", Encoding.UTF8, "text/plain")
|
|
});
|
|
});
|
|
var adapter = CreateAdapter(httpHandler);
|
|
|
|
var logs = await adapter.GetBackgroundCommandLogsAsync("cmd-3");
|
|
|
|
logs.Content.Should().Be("only-content");
|
|
logs.Cursor.Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunStreamAsync_ShouldSendTimeoutInMilliseconds()
|
|
{
|
|
var handler = new StubHttpMessageHandler(async (request, _) =>
|
|
{
|
|
request.Content.Should().NotBeNull();
|
|
var body = await request.Content!.ReadAsStringAsync().ConfigureAwait(false);
|
|
using var doc = JsonDocument.Parse(body);
|
|
doc.RootElement.GetProperty("timeout").GetInt64().Should().Be(2000);
|
|
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent("data: {\"type\":\"init\",\"text\":\"cmd-1\"}\n\n", Encoding.UTF8, "text/event-stream")
|
|
};
|
|
});
|
|
var adapter = CreateAdapter(handler);
|
|
|
|
var options = new RunCommandOptions
|
|
{
|
|
TimeoutSeconds = 2
|
|
};
|
|
|
|
await foreach (var _ in adapter.RunStreamAsync("sleep 1", options))
|
|
{
|
|
// Drain events.
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunStreamAsync_ShouldSendUidGidAndEnvs()
|
|
{
|
|
var handler = new StubHttpMessageHandler(async (request, _) =>
|
|
{
|
|
request.Content.Should().NotBeNull();
|
|
var body = await request.Content!.ReadAsStringAsync().ConfigureAwait(false);
|
|
using var doc = JsonDocument.Parse(body);
|
|
doc.RootElement.GetProperty("uid").GetInt32().Should().Be(1000);
|
|
doc.RootElement.GetProperty("gid").GetInt32().Should().Be(1000);
|
|
var envs = doc.RootElement.GetProperty("envs");
|
|
envs.GetProperty("APP_ENV").GetString().Should().Be("test");
|
|
envs.GetProperty("LOG_LEVEL").GetString().Should().Be("debug");
|
|
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent("data: {\"type\":\"init\",\"text\":\"cmd-1\"}\n\n", Encoding.UTF8, "text/event-stream")
|
|
};
|
|
});
|
|
var adapter = CreateAdapter(handler);
|
|
|
|
var options = new RunCommandOptions
|
|
{
|
|
Uid = 1000,
|
|
Gid = 1000,
|
|
Envs = new Dictionary<string, string>
|
|
{
|
|
["APP_ENV"] = "test",
|
|
["LOG_LEVEL"] = "debug"
|
|
}
|
|
};
|
|
|
|
await foreach (var _ in adapter.RunStreamAsync("id", options))
|
|
{
|
|
// Drain events.
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunStreamAsync_ShouldRejectGidWithoutUid()
|
|
{
|
|
var handler = new StubHttpMessageHandler((_, _) =>
|
|
{
|
|
throw new InvalidOperationException("HTTP should not be called when options are invalid.");
|
|
});
|
|
var adapter = CreateAdapter(handler);
|
|
|
|
var options = new RunCommandOptions
|
|
{
|
|
Gid = 1000
|
|
};
|
|
|
|
var act = async () =>
|
|
{
|
|
await foreach (var _ in adapter.RunStreamAsync("id", options))
|
|
{
|
|
// Drain events.
|
|
}
|
|
};
|
|
|
|
await act.Should().ThrowAsync<InvalidArgumentException>()
|
|
.WithMessage("*uid is required when gid is provided*");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunAsync_ShouldPopulateCompleteAndExitCodeForSuccessfulForegroundCommands()
|
|
{
|
|
var handler = new StubHttpMessageHandler((_, _) =>
|
|
{
|
|
const string sse = """
|
|
data: {"type":"init","text":"cmd-1","timestamp":1}
|
|
|
|
data: {"type":"stdout","text":"hi","timestamp":2}
|
|
|
|
data: {"type":"execution_complete","timestamp":3,"execution_time":4}
|
|
|
|
""";
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(sse, Encoding.UTF8, "text/event-stream")
|
|
});
|
|
});
|
|
var adapter = CreateAdapter(handler);
|
|
|
|
var execution = await adapter.RunAsync("echo hi");
|
|
|
|
execution.Id.Should().Be("cmd-1");
|
|
execution.Logs.Stdout.Should().ContainSingle().Which.Text.Should().Be("hi");
|
|
execution.Complete.Should().NotBeNull();
|
|
execution.Complete!.ExecutionTimeMs.Should().Be(4);
|
|
execution.ExitCode.Should().Be(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunAsync_ShouldInferNonZeroExitCodeFromFinalErrorState()
|
|
{
|
|
var handler = new StubHttpMessageHandler((_, _) =>
|
|
{
|
|
const string sse = """
|
|
data: {"type":"init","text":"cmd-2","timestamp":1}
|
|
|
|
data: {"type":"error","error":{"ename":"CommandExecError","evalue":"7","traceback":["exit status 7"]},"timestamp":2}
|
|
|
|
""";
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(sse, Encoding.UTF8, "text/event-stream")
|
|
});
|
|
});
|
|
var adapter = CreateAdapter(handler);
|
|
|
|
var execution = await adapter.RunAsync("exit 7");
|
|
|
|
execution.Id.Should().Be("cmd-2");
|
|
execution.Error.Should().NotBeNull();
|
|
execution.Error!.Value.Should().Be("7");
|
|
execution.Complete.Should().BeNull();
|
|
execution.ExitCode.Should().Be(7);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunAsync_ShouldKeepExitCodeNullWhenErrorValueIsEmpty()
|
|
{
|
|
var handler = new StubHttpMessageHandler((_, _) =>
|
|
{
|
|
const string sse = """
|
|
data: {"type":"init","text":"cmd-3","timestamp":1}
|
|
|
|
data: {"type":"execution_complete","timestamp":2,"execution_time":4}
|
|
|
|
data: {"type":"error","error":{"ename":"CommandExecError","evalue":"","traceback":["failed"]},"timestamp":3}
|
|
|
|
""";
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(sse, Encoding.UTF8, "text/event-stream")
|
|
});
|
|
});
|
|
var adapter = CreateAdapter(handler);
|
|
|
|
var execution = await adapter.RunAsync("bad command");
|
|
|
|
execution.Id.Should().Be("cmd-3");
|
|
execution.Error.Should().NotBeNull();
|
|
execution.Error!.Value.Should().BeEmpty();
|
|
execution.Complete.Should().NotBeNull();
|
|
execution.ExitCode.Should().BeNull();
|
|
}
|
|
|
|
// --- Bash session API integration tests ---
|
|
|
|
[Fact]
|
|
public async Task CreateSessionAsync_ShouldReturnSessionId_WhenWorkingDirectoryProvided()
|
|
{
|
|
var handler = new StubHttpMessageHandler(async (request, _) =>
|
|
{
|
|
request.Method.Should().Be(HttpMethod.Post);
|
|
request.RequestUri!.ToString().Should().Contain("/session");
|
|
request.Content.Should().NotBeNull();
|
|
var body = await request.Content!.ReadAsStringAsync().ConfigureAwait(false);
|
|
using var doc = JsonDocument.Parse(body);
|
|
doc.RootElement.GetProperty("cwd").GetString().Should().Be("/tmp");
|
|
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent("{\"session_id\":\"sess-abc123\"}", Encoding.UTF8, "application/json")
|
|
};
|
|
});
|
|
var adapter = CreateAdapter(handler);
|
|
|
|
var sessionId = await adapter.CreateSessionAsync(new CreateSessionOptions { WorkingDirectory = "/tmp" });
|
|
|
|
sessionId.Should().Be("sess-abc123");
|
|
handler.RequestUris.Should().Contain(uri => uri.EndsWith("/session"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateSessionAsync_ShouldReturnSessionId_WhenNoOptions()
|
|
{
|
|
var handler = new StubHttpMessageHandler((request, _) =>
|
|
{
|
|
request.Method.Should().Be(HttpMethod.Post);
|
|
request.RequestUri!.ToString().Should().Contain("/session");
|
|
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent("{\"session_id\":\"sess-default\"}", Encoding.UTF8, "application/json")
|
|
});
|
|
});
|
|
var adapter = CreateAdapter(handler);
|
|
|
|
var sessionId = await adapter.CreateSessionAsync();
|
|
|
|
sessionId.Should().Be("sess-default");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateSessionAsync_ShouldThrow_WhenResponseHasEmptySessionId()
|
|
{
|
|
var handler = new StubHttpMessageHandler((_, _) =>
|
|
{
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent("{\"session_id\":\"\"}", Encoding.UTF8, "application/json")
|
|
});
|
|
});
|
|
var adapter = CreateAdapter(handler);
|
|
|
|
var act = () => adapter.CreateSessionAsync();
|
|
|
|
await act.Should().ThrowAsync<SandboxApiException>()
|
|
.WithMessage("*empty session_id*");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunInSessionAsync_ShouldSendCommandAndOptions()
|
|
{
|
|
var handler = new StubHttpMessageHandler(async (request, _) =>
|
|
{
|
|
request.Method.Should().Be(HttpMethod.Post);
|
|
request.RequestUri!.ToString().Should().Contain("/session/sess-1/run");
|
|
request.Content.Should().NotBeNull();
|
|
var body = await request.Content!.ReadAsStringAsync().ConfigureAwait(false);
|
|
using var doc = JsonDocument.Parse(body);
|
|
doc.RootElement.GetProperty("command").GetString().Should().Be("pwd");
|
|
doc.RootElement.GetProperty("cwd").GetString().Should().Be("/var");
|
|
doc.RootElement.GetProperty("timeout").GetInt64().Should().Be(5000);
|
|
|
|
var sse = "data: {\"type\":\"stdout\",\"text\":\"/var\"}\ndata: {\"type\":\"execution_complete\"}\n";
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(sse, Encoding.UTF8, "text/event-stream")
|
|
};
|
|
});
|
|
var adapter = CreateAdapter(handler);
|
|
|
|
var run = await adapter.RunInSessionAsync(
|
|
"sess-1",
|
|
"pwd",
|
|
new RunInSessionOptions { WorkingDirectory = "/var", TimeoutSeconds = 5 });
|
|
|
|
run.Should().NotBeNull();
|
|
run.Logs.Stdout.Should().ContainSingle(m => m.Text == "/var");
|
|
run.ExitCode.Should().Be(0);
|
|
handler.RequestUris.Should().Contain(uri => uri.Contains("/session/sess-1/run"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunInSessionAsync_ShouldInferNonZeroExitCodeFromFinalErrorState()
|
|
{
|
|
var handler = new StubHttpMessageHandler((_, _) =>
|
|
{
|
|
const string sse = """
|
|
data: {"type":"init","text":"sess-cmd-2","timestamp":1}
|
|
|
|
data: {"type":"error","error":{"ename":"CommandExecError","evalue":"7","traceback":["exit status 7"]},"timestamp":2}
|
|
|
|
""";
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(sse, Encoding.UTF8, "text/event-stream")
|
|
});
|
|
});
|
|
var adapter = CreateAdapter(handler);
|
|
|
|
var execution = await adapter.RunInSessionAsync("sess-2", "exit 7");
|
|
|
|
execution.Id.Should().Be("sess-cmd-2");
|
|
execution.Error.Should().NotBeNull();
|
|
execution.Error!.Value.Should().Be("7");
|
|
execution.Complete.Should().BeNull();
|
|
execution.ExitCode.Should().Be(7);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunInSessionAsync_ShouldThrow_WhenSessionIdEmpty()
|
|
{
|
|
var adapter = CreateAdapter(new StubHttpMessageHandler((_, _) => throw new InvalidOperationException("Should not be called")));
|
|
|
|
var act = () => adapter.RunInSessionAsync("", "echo hi");
|
|
|
|
await act.Should().ThrowAsync<InvalidArgumentException>()
|
|
.WithMessage("*sessionId*");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunInSessionAsync_ShouldThrow_WhenCommandEmpty()
|
|
{
|
|
var adapter = CreateAdapter(new StubHttpMessageHandler((_, _) => throw new InvalidOperationException("Should not be called")));
|
|
|
|
var act = () => adapter.RunInSessionAsync("sess-1", " ");
|
|
|
|
await act.Should().ThrowAsync<InvalidArgumentException>()
|
|
.WithMessage("*command*");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteSessionAsync_ShouldCallDeleteEndpoint()
|
|
{
|
|
var handler = new StubHttpMessageHandler((request, _) =>
|
|
{
|
|
request.Method.Should().Be(HttpMethod.Delete);
|
|
request.RequestUri!.ToString().Should().Contain("/session/sess-to-delete");
|
|
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
|
|
});
|
|
var adapter = CreateAdapter(handler);
|
|
|
|
await adapter.DeleteSessionAsync("sess-to-delete");
|
|
|
|
handler.RequestUris.Should().Contain(uri => uri.EndsWith("/session/sess-to-delete"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteSessionAsync_ShouldThrow_WhenSessionIdEmpty()
|
|
{
|
|
var adapter = CreateAdapter(new StubHttpMessageHandler((_, _) => throw new InvalidOperationException("Should not be called")));
|
|
|
|
var act = () => adapter.DeleteSessionAsync(" ");
|
|
|
|
await act.Should().ThrowAsync<InvalidArgumentException>()
|
|
.WithMessage("*sessionId*");
|
|
}
|
|
|
|
private static CommandsAdapter CreateAdapter(HttpMessageHandler httpHandler)
|
|
{
|
|
var baseUrl = "http://execd.local";
|
|
var headers = new Dictionary<string, string> { ["X-Test"] = "true" };
|
|
var client = new HttpClientWrapper(new HttpClient(httpHandler), baseUrl, headers);
|
|
var sseHttpClient = new HttpClient(httpHandler);
|
|
var logger = NullLoggerFactory.Instance.CreateLogger("CommandsAdapterTests");
|
|
return new CommandsAdapter(client, sseHttpClient, baseUrl, headers, logger);
|
|
}
|
|
|
|
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
|
{
|
|
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handler;
|
|
|
|
public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler)
|
|
{
|
|
_handler = handler;
|
|
}
|
|
|
|
public List<string> RequestUris { get; } = new();
|
|
|
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
RequestUris.Add(request.RequestUri?.ToString() ?? string.Empty);
|
|
return await _handler(request, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|