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
499 lines
15 KiB
Go
499 lines
15 KiB
Go
// Copyright 2025 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.
|
|
|
|
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
goruntime "runtime"
|
|
|
|
"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestReadFromPos_SplitsOnCRAndLF(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
logFile := filepath.Join(tmp, "stdout.log")
|
|
|
|
mutex := &sync.Mutex{}
|
|
|
|
initial := "line1\nprog 10%\rprog 20%\rprog 30%\nlast\n"
|
|
require.NoError(t, os.WriteFile(logFile, []byte(initial), 0o644))
|
|
|
|
var got []string
|
|
c := &Controller{}
|
|
nextPos := c.readFromPos(mutex, logFile, 0, func(s string) { got = append(got, s) }, false, nil)
|
|
|
|
want := []string{"line1", "prog 10%", "prog 20%", "prog 30%", "last"}
|
|
require.Len(t, got, len(want))
|
|
for i := range want {
|
|
require.Equal(t, want[i], got[i], "token[%d] mismatch", i)
|
|
}
|
|
|
|
// append more content and ensure incremental read only yields the new part
|
|
appendPart := "tail1\r\ntail2\n"
|
|
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(appendPart)
|
|
require.NoError(t, err, "append write")
|
|
_ = f.Close()
|
|
|
|
got = got[:0]
|
|
c.readFromPos(mutex, logFile, nextPos, func(s string) { got = append(got, s) }, false, nil)
|
|
want = []string{"tail1", "tail2"}
|
|
require.Len(t, got, len(want))
|
|
for i := range want {
|
|
require.Equal(t, want[i], got[i], "incremental token[%d] mismatch", i)
|
|
}
|
|
}
|
|
|
|
func TestReadFromPos_LongLine(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
logFile := filepath.Join(tmp, "stdout.log")
|
|
|
|
// construct a single line larger than the default 64KB, but under 5MB
|
|
longLine := strings.Repeat("x", 256*1024) + "\n" // 256KB
|
|
require.NoError(t, os.WriteFile(logFile, []byte(longLine), 0o644))
|
|
|
|
var got []string
|
|
c := &Controller{}
|
|
c.readFromPos(&sync.Mutex{}, logFile, 0, func(s string) { got = append(got, s) }, false, nil)
|
|
|
|
require.Len(t, got, 1, "expected one token")
|
|
require.Equal(t, strings.TrimSuffix(longLine, "\n"), got[0], "long line mismatch")
|
|
}
|
|
|
|
func TestReadFromPos_FlushesTrailingLine(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
file := filepath.Join(tmpDir, "stdout.log")
|
|
content := []byte("line1\nlastline-without-newline")
|
|
err := os.WriteFile(file, content, 0o644)
|
|
assert.NoError(t, err)
|
|
|
|
c := NewController("", "")
|
|
mutex := &sync.Mutex{}
|
|
var lines []string
|
|
onExecute := func(text string) {
|
|
lines = append(lines, text)
|
|
}
|
|
|
|
// First read: should only get complete lines with newlines
|
|
pos := c.readFromPos(mutex, file, 0, onExecute, false, nil)
|
|
assert.GreaterOrEqual(t, pos, int64(0))
|
|
assert.Equal(t, []string{"line1"}, lines)
|
|
|
|
// Flush at end: should output the last line (without newline)
|
|
c.readFromPos(mutex, file, pos, onExecute, true, nil)
|
|
assert.Equal(t, []string{"line1", "lastline-without-newline"}, lines)
|
|
}
|
|
|
|
func TestReadFromPos_PreservesBlankLines(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
logFile := filepath.Join(tmp, "stdout.log")
|
|
|
|
// Mix of single newlines, consecutive blank lines, leading blank, and CRLF.
|
|
initial := "a\n\nb\n\n\nc\n\r\nd\n"
|
|
require.NoError(t, os.WriteFile(logFile, []byte(initial), 0o644))
|
|
|
|
var got []string
|
|
c := &Controller{}
|
|
c.readFromPos(&sync.Mutex{}, logFile, 0, func(s string) { got = append(got, s) }, false, nil)
|
|
|
|
want := []string{"a", "\n", "b", "\n", "\n", "c", "\n", "d"}
|
|
require.Equal(t, want, got)
|
|
}
|
|
|
|
// TestReadFromPos_CRLFAcrossPolls ensures a \r\n pair that arrives in two
|
|
// successive polls does not emit a spurious blank line for the trailing \n.
|
|
// Reproduces the regression on Windows/cmd writers that flush \r before \n.
|
|
func TestReadFromPos_CRLFAcrossPolls(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
logFile := filepath.Join(tmp, "stdout.log")
|
|
|
|
require.NoError(t, os.WriteFile(logFile, []byte("a\r"), 0o644))
|
|
|
|
var got []string
|
|
c := &Controller{}
|
|
mutex := &sync.Mutex{}
|
|
var lastWasCR bool
|
|
pos := c.readFromPos(mutex, logFile, 0, func(s string) { got = append(got, s) }, false, &lastWasCR)
|
|
require.Equal(t, []string{"a"}, got)
|
|
require.True(t, lastWasCR, "CR state must persist for next poll")
|
|
|
|
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString("\nb\n")
|
|
require.NoError(t, err)
|
|
_ = f.Close()
|
|
|
|
got = got[:0]
|
|
c.readFromPos(mutex, logFile, pos, func(s string) { got = append(got, s) }, false, &lastWasCR)
|
|
require.Equal(t, []string{"b"}, got, "trailing \\n of split CRLF must not emit a blank line")
|
|
}
|
|
|
|
// TestReadFromPos_BlankCRLFAcrossPolls ensures a blank \r\n line split across
|
|
// polls is emitted as a single blank, not duplicated.
|
|
func TestReadFromPos_BlankCRLFAcrossPolls(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
logFile := filepath.Join(tmp, "stdout.log")
|
|
|
|
require.NoError(t, os.WriteFile(logFile, []byte("\r"), 0o644))
|
|
|
|
var got []string
|
|
c := &Controller{}
|
|
mutex := &sync.Mutex{}
|
|
var lastWasCR bool
|
|
pos := c.readFromPos(mutex, logFile, 0, func(s string) { got = append(got, s) }, false, &lastWasCR)
|
|
require.Equal(t, []string{"\n"}, got)
|
|
require.True(t, lastWasCR)
|
|
|
|
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString("\n")
|
|
require.NoError(t, err)
|
|
_ = f.Close()
|
|
|
|
got = got[:0]
|
|
c.readFromPos(mutex, logFile, pos, func(s string) { got = append(got, s) }, false, &lastWasCR)
|
|
require.Empty(t, got, "trailing \\n of split blank CRLF must not emit a second blank")
|
|
}
|
|
|
|
func TestRunCommand_Echo(t *testing.T) {
|
|
if goruntime.GOOS == "windows" {
|
|
t.Skip("bash not available on windows")
|
|
}
|
|
if _, err := exec.LookPath("bash"); err != nil {
|
|
t.Skip("bash not found in PATH")
|
|
}
|
|
|
|
c := NewController("", "")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
var (
|
|
sessionID string
|
|
stdoutLines []string
|
|
stderrLines []string
|
|
completeCh = make(chan struct{}, 1)
|
|
)
|
|
|
|
req := &ExecuteCodeRequest{
|
|
Code: `echo "hello"; echo "errline" 1>&2`,
|
|
Cwd: t.TempDir(),
|
|
Timeout: 5 * time.Second,
|
|
Hooks: ExecuteResultHook{
|
|
OnExecuteInit: func(s string) { sessionID = s },
|
|
OnExecuteStdout: func(s string) {
|
|
stdoutLines = append(stdoutLines, s)
|
|
},
|
|
OnExecuteStderr: func(s string) {
|
|
stderrLines = append(stderrLines, s)
|
|
},
|
|
OnExecuteError: func(err *execute.ErrorOutput) {
|
|
require.Failf(t, "unexpected error hook", "%+v", err)
|
|
},
|
|
OnExecuteComplete: func(_ time.Duration) {
|
|
completeCh <- struct{}{}
|
|
},
|
|
},
|
|
}
|
|
|
|
require.NoError(t, c.runCommand(ctx, req))
|
|
|
|
select {
|
|
case <-completeCh:
|
|
case <-time.After(2 * time.Second):
|
|
require.Fail(t, "timeout waiting for completion hook")
|
|
}
|
|
|
|
require.NotEmpty(t, sessionID, "expected session id to be set")
|
|
require.Equal(t, []string{"hello"}, stdoutLines)
|
|
require.Equal(t, []string{"errline"}, stderrLines)
|
|
}
|
|
|
|
func TestRunCommand_Error(t *testing.T) {
|
|
if goruntime.GOOS == "windows" {
|
|
t.Skip("bash not available on windows")
|
|
}
|
|
if _, err := exec.LookPath("bash"); err != nil {
|
|
t.Skip("bash not found in PATH")
|
|
}
|
|
|
|
c := NewController("", "")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
var (
|
|
sessionID string
|
|
gotErr *execute.ErrorOutput
|
|
completeCh = make(chan struct{}, 2)
|
|
stdoutLines []string
|
|
stderrLines []string
|
|
)
|
|
|
|
req := &ExecuteCodeRequest{
|
|
Code: `echo "before"; exit 3`,
|
|
Cwd: t.TempDir(),
|
|
Timeout: 5 * time.Second,
|
|
Hooks: ExecuteResultHook{
|
|
OnExecuteInit: func(s string) { sessionID = s },
|
|
OnExecuteStdout: func(s string) { stdoutLines = append(stdoutLines, s) },
|
|
OnExecuteStderr: func(s string) { stderrLines = append(stderrLines, s) },
|
|
OnExecuteError: func(err *execute.ErrorOutput) {
|
|
gotErr = err
|
|
completeCh <- struct{}{}
|
|
},
|
|
OnExecuteComplete: func(_ time.Duration) {
|
|
completeCh <- struct{}{}
|
|
},
|
|
},
|
|
}
|
|
|
|
require.NoError(t, c.runCommand(ctx, req))
|
|
|
|
select {
|
|
case <-completeCh:
|
|
case <-time.After(2 * time.Second):
|
|
require.Fail(t, "timeout waiting for completion hook")
|
|
}
|
|
|
|
require.NotEmpty(t, sessionID, "expected session id to be set")
|
|
require.Equal(t, []string{"before"}, stdoutLines)
|
|
require.Empty(t, stderrLines, "expected no stderr")
|
|
require.NotNil(t, gotErr, "expected error hook to be called")
|
|
require.Equal(t, "CommandExecError", gotErr.EName)
|
|
require.Equal(t, "3", gotErr.EValue)
|
|
}
|
|
|
|
func TestRunCommand_ExpandsHomeInCwd(t *testing.T) {
|
|
if goruntime.GOOS == "windows" {
|
|
t.Skip("bash not available on windows")
|
|
}
|
|
if _, err := exec.LookPath("bash"); err != nil {
|
|
t.Skip("bash not found in PATH")
|
|
}
|
|
|
|
home := t.TempDir()
|
|
target := filepath.Join(home, "workspace")
|
|
require.NoError(t, os.MkdirAll(target, 0o755))
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
|
|
c := NewController("", "")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
var stdoutLines []string
|
|
req := &ExecuteCodeRequest{
|
|
Code: `pwd`,
|
|
Cwd: "~/workspace",
|
|
Timeout: 5 * time.Second,
|
|
Hooks: ExecuteResultHook{
|
|
OnExecuteInit: func(_ string) {},
|
|
OnExecuteStdout: func(s string) { stdoutLines = append(stdoutLines, s) },
|
|
OnExecuteStderr: func(_ string) {},
|
|
OnExecuteError: func(err *execute.ErrorOutput) {
|
|
require.Failf(t, "unexpected error hook", "%+v", err)
|
|
},
|
|
OnExecuteComplete: func(_ time.Duration) {},
|
|
},
|
|
}
|
|
|
|
require.NoError(t, c.runCommand(ctx, req))
|
|
|
|
targetRealPath, err := filepath.EvalSymlinks(target)
|
|
require.NoError(t, err)
|
|
targetRealPath = filepath.Clean(targetRealPath)
|
|
|
|
found := false
|
|
for _, line := range stdoutLines {
|
|
p := strings.TrimSpace(line)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
pRealPath, err := filepath.EvalSymlinks(p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if filepath.Clean(pRealPath) == targetRealPath {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
require.True(t, found, "pwd output does not match expected cwd; got=%v target=%s", stdoutLines, target)
|
|
}
|
|
|
|
func TestRunCommand_ExpandsCwdFromRequestEnvWithHigherPriority(t *testing.T) {
|
|
if goruntime.GOOS == "windows" {
|
|
t.Skip("bash not available on windows")
|
|
}
|
|
if _, err := exec.LookPath("bash"); err != nil {
|
|
t.Skip("bash not found in PATH")
|
|
}
|
|
|
|
processDir := t.TempDir()
|
|
requestDir := t.TempDir()
|
|
t.Setenv("WORKDIR", processDir)
|
|
|
|
c := NewController("", "")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
var (
|
|
stdoutLines []string
|
|
gotErr *execute.ErrorOutput
|
|
)
|
|
req := &ExecuteCodeRequest{
|
|
Code: `pwd`,
|
|
Cwd: `$WORKDIR`,
|
|
Timeout: 5 * time.Second,
|
|
Envs: map[string]string{
|
|
"WORKDIR": requestDir,
|
|
},
|
|
Hooks: ExecuteResultHook{
|
|
OnExecuteInit: func(_ string) {},
|
|
OnExecuteStdout: func(s string) { stdoutLines = append(stdoutLines, s) },
|
|
OnExecuteStderr: func(_ string) {},
|
|
OnExecuteError: func(err *execute.ErrorOutput) {
|
|
gotErr = err
|
|
},
|
|
OnExecuteComplete: func(_ time.Duration) {},
|
|
},
|
|
}
|
|
|
|
require.NoError(t, c.runCommand(ctx, req))
|
|
require.Nil(t, gotErr, "expected cwd expansion to use request env")
|
|
|
|
requestRealPath, err := filepath.EvalSymlinks(requestDir)
|
|
require.NoError(t, err)
|
|
requestRealPath = filepath.Clean(requestRealPath)
|
|
|
|
found := false
|
|
for _, line := range stdoutLines {
|
|
p := strings.TrimSpace(line)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
pRealPath, err := filepath.EvalSymlinks(p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if filepath.Clean(pRealPath) == requestRealPath {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
require.True(t, found, "pwd output does not match request env cwd; got=%v requestDir=%s", stdoutLines, requestDir)
|
|
}
|
|
|
|
func TestRunCommand_StartErrorIncludesTraceback(t *testing.T) {
|
|
if goruntime.GOOS == "windows" {
|
|
t.Skip("bash not available on windows")
|
|
}
|
|
if _, err := exec.LookPath("bash"); err != nil {
|
|
t.Skip("bash not found in PATH")
|
|
}
|
|
|
|
c := NewController("", "")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
var (
|
|
sessionID string
|
|
gotErr *execute.ErrorOutput
|
|
completeCalled bool
|
|
)
|
|
|
|
req := &ExecuteCodeRequest{
|
|
Code: `echo "hello"`,
|
|
Cwd: filepath.Join(t.TempDir(), "missing"),
|
|
Timeout: 5 * time.Second,
|
|
Hooks: ExecuteResultHook{
|
|
OnExecuteInit: func(s string) { sessionID = s },
|
|
OnExecuteError: func(err *execute.ErrorOutput) {
|
|
gotErr = err
|
|
},
|
|
OnExecuteComplete: func(_ time.Duration) {
|
|
completeCalled = true
|
|
},
|
|
},
|
|
}
|
|
|
|
require.NoError(t, c.runCommand(ctx, req))
|
|
|
|
require.NotEmpty(t, sessionID, "expected session id to be set")
|
|
require.NotNil(t, gotErr, "expected error hook to be called")
|
|
require.Equal(t, "CommandExecError", gotErr.EName)
|
|
require.NotEmpty(t, gotErr.Traceback, "expected traceback to be populated")
|
|
require.Equal(t, gotErr.EValue, gotErr.Traceback[0])
|
|
require.False(t, completeCalled, "did not expect completion hook on start failure")
|
|
}
|
|
|
|
// TestStdLogDescriptor_AutoCreatesTempDir verifies that stdLogDescriptor
|
|
// recreates the temp directory when it has been deleted, rather than failing.
|
|
// Regression test for https://github.com/alibaba/OpenSandbox/issues/400.
|
|
func TestStdLogDescriptor_AutoCreatesTempDir(t *testing.T) {
|
|
if goruntime.GOOS == "windows" {
|
|
t.Skip("TMPDIR env var has no effect on Windows")
|
|
}
|
|
|
|
// Point os.TempDir() at a path that does not yet exist.
|
|
missingDir := filepath.Join(t.TempDir(), "deleted_tmp")
|
|
t.Setenv("TMPDIR", missingDir)
|
|
|
|
c := NewController("", "")
|
|
stdout, stderr, err := c.stdLogDescriptor("test-session")
|
|
require.NoError(t, err)
|
|
stdout.Close()
|
|
stderr.Close()
|
|
|
|
// The directory must have been created.
|
|
info, err := os.Stat(missingDir)
|
|
require.NoError(t, err, "expected temp dir to be created, stat error")
|
|
require.True(t, info.IsDir(), "expected %s to be a directory", missingDir)
|
|
}
|
|
|
|
// TestCombinedOutputDescriptor_AutoCreatesTempDir verifies that
|
|
// combinedOutputDescriptor also recreates the temp directory when missing.
|
|
// Regression test for https://github.com/alibaba/OpenSandbox/issues/400.
|
|
func TestCombinedOutputDescriptor_AutoCreatesTempDir(t *testing.T) {
|
|
if goruntime.GOOS == "windows" {
|
|
t.Skip("TMPDIR env var has no effect on Windows")
|
|
}
|
|
|
|
missingDir := filepath.Join(t.TempDir(), "deleted_tmp")
|
|
t.Setenv("TMPDIR", missingDir)
|
|
|
|
c := NewController("", "")
|
|
f, err := c.combinedOutputDescriptor("test-session")
|
|
require.NoError(t, err)
|
|
f.Close()
|
|
|
|
info, err := os.Stat(missingDir)
|
|
require.NoError(t, err, "expected temp dir to be created, stat error")
|
|
require.True(t, info.IsDir(), "expected %s to be a directory", missingDir)
|
|
}
|