Pure Go JavaScript/TypeScript execution engine using goja + esbuild. Provides sandboxed code execution with a call() bridge for agent actions, automatic TypeScript detection and transpilation, timeout enforcement, max-calls limits, and a concurrent execution pool. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
525 lines
12 KiB
Go
525 lines
12 KiB
Go
package jsruntime
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// mockCaller implements ToolCaller for testing.
|
|
type mockCaller struct {
|
|
calls []mockCall
|
|
results map[string]any
|
|
errors map[string]error
|
|
}
|
|
|
|
type mockCall struct {
|
|
Action string
|
|
Args map[string]any
|
|
}
|
|
|
|
func newMockCaller() *mockCaller {
|
|
return &mockCaller{
|
|
results: make(map[string]any),
|
|
errors: make(map[string]error),
|
|
}
|
|
}
|
|
|
|
func (m *mockCaller) Call(_ context.Context, actionName string, args map[string]any) (any, error) {
|
|
m.calls = append(m.calls, mockCall{Action: actionName, Args: args})
|
|
if err, ok := m.errors[actionName]; ok {
|
|
return nil, err
|
|
}
|
|
if result, ok := m.results[actionName]; ok {
|
|
return result, nil
|
|
}
|
|
return map[string]any{"ok": true}, nil
|
|
}
|
|
|
|
func TestExecute(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
code string
|
|
opts ExecuteOptions
|
|
wantValue any
|
|
wantErr string // substring match on error code
|
|
}{
|
|
{
|
|
name: "simple integer",
|
|
code: `42`,
|
|
wantValue: int64(42),
|
|
},
|
|
{
|
|
name: "simple string",
|
|
code: `"hello"`,
|
|
wantValue: "hello",
|
|
},
|
|
{
|
|
name: "object literal",
|
|
code: `({ a: 1, b: "two" })`,
|
|
},
|
|
{
|
|
name: "arithmetic expression",
|
|
code: `2 + 3 * 4`,
|
|
},
|
|
{
|
|
name: "null value",
|
|
code: `null`,
|
|
wantValue: nil,
|
|
},
|
|
{
|
|
name: "array",
|
|
code: `[1, 2, 3]`,
|
|
},
|
|
{
|
|
name: "boolean true",
|
|
code: `true`,
|
|
wantValue: true,
|
|
},
|
|
{
|
|
name: "boolean false",
|
|
code: `false`,
|
|
wantValue: false,
|
|
},
|
|
{
|
|
name: "empty code",
|
|
code: "",
|
|
wantValue: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
caller := newMockCaller()
|
|
result, err := Execute(context.Background(), tt.code, caller, tt.opts)
|
|
|
|
if tt.wantErr != "" {
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
|
}
|
|
execErr, ok := err.(*ExecError)
|
|
if !ok {
|
|
t.Fatalf("expected *ExecError, got %T: %v", err, err)
|
|
}
|
|
if execErr.Code != tt.wantErr {
|
|
t.Errorf("expected error code %q, got %q", tt.wantErr, execErr.Code)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if tt.wantValue != nil && result.Value != tt.wantValue {
|
|
t.Errorf("expected value %v (%T), got %v (%T)", tt.wantValue, tt.wantValue, result.Value, result.Value)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExecute_SyntaxError(t *testing.T) {
|
|
caller := newMockCaller()
|
|
_, err := Execute(context.Background(), `{ invalid syntax`, caller, ExecuteOptions{})
|
|
|
|
if err == nil {
|
|
t.Fatal("expected syntax error, got nil")
|
|
}
|
|
execErr, ok := err.(*ExecError)
|
|
if !ok {
|
|
t.Fatalf("expected *ExecError, got %T", err)
|
|
}
|
|
if execErr.Code != CodeSyntaxError {
|
|
t.Errorf("expected code %q, got %q", CodeSyntaxError, execErr.Code)
|
|
}
|
|
}
|
|
|
|
func TestExecute_RuntimeError(t *testing.T) {
|
|
caller := newMockCaller()
|
|
_, err := Execute(context.Background(), `throw new Error("boom")`, caller, ExecuteOptions{})
|
|
|
|
if err == nil {
|
|
t.Fatal("expected runtime error, got nil")
|
|
}
|
|
execErr, ok := err.(*ExecError)
|
|
if !ok {
|
|
t.Fatalf("expected *ExecError, got %T", err)
|
|
}
|
|
if execErr.Code != CodeRuntimeError {
|
|
t.Errorf("expected code %q, got %q", CodeRuntimeError, execErr.Code)
|
|
}
|
|
if execErr.Stack == "" {
|
|
t.Error("expected non-empty stack trace for runtime error")
|
|
}
|
|
}
|
|
|
|
func TestExecute_Timeout(t *testing.T) {
|
|
caller := newMockCaller()
|
|
start := time.Now()
|
|
_, err := Execute(context.Background(), `while(true) {}`, caller, ExecuteOptions{
|
|
Timeout: 100 * time.Millisecond,
|
|
})
|
|
elapsed := time.Since(start)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected timeout error, got nil")
|
|
}
|
|
execErr, ok := err.(*ExecError)
|
|
if !ok {
|
|
t.Fatalf("expected *ExecError, got %T", err)
|
|
}
|
|
if execErr.Code != CodeTimeout {
|
|
t.Errorf("expected code %q, got %q", CodeTimeout, execErr.Code)
|
|
}
|
|
|
|
// Should complete within a reasonable margin of the timeout
|
|
if elapsed > 2*time.Second {
|
|
t.Errorf("timeout took too long: %v", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestExecute_CallBridge(t *testing.T) {
|
|
caller := newMockCaller()
|
|
caller.results["get_user"] = map[string]any{
|
|
"name": "alice",
|
|
"id": 42,
|
|
}
|
|
|
|
code := `
|
|
var res = call("get_user", { id: 1 });
|
|
if (!res.ok) throw new Error("failed");
|
|
({ name: res.result.name })
|
|
`
|
|
result, err := Execute(context.Background(), code, caller, ExecuteOptions{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(caller.calls) != 1 {
|
|
t.Fatalf("expected 1 call, got %d", len(caller.calls))
|
|
}
|
|
if caller.calls[0].Action != "get_user" {
|
|
t.Errorf("expected action 'get_user', got %q", caller.calls[0].Action)
|
|
}
|
|
if result.CallCount != 1 {
|
|
t.Errorf("expected CallCount=1, got %d", result.CallCount)
|
|
}
|
|
|
|
resultMap, ok := result.Value.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected map result, got %T", result.Value)
|
|
}
|
|
if resultMap["name"] != "alice" {
|
|
t.Errorf("expected name='alice', got %v", resultMap["name"])
|
|
}
|
|
}
|
|
|
|
func TestExecute_CallBridgeError(t *testing.T) {
|
|
caller := newMockCaller()
|
|
caller.errors["fail_action"] = fmt.Errorf("upstream error")
|
|
|
|
code := `
|
|
var res = call("fail_action", {});
|
|
({ ok: res.ok, code: res.error.code })
|
|
`
|
|
result, err := Execute(context.Background(), code, caller, ExecuteOptions{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
resultMap := result.Value.(map[string]any)
|
|
if resultMap["ok"] != false {
|
|
t.Errorf("expected ok=false, got %v", resultMap["ok"])
|
|
}
|
|
if resultMap["code"] != "CALL_ERROR" {
|
|
t.Errorf("expected code='CALL_ERROR', got %v", resultMap["code"])
|
|
}
|
|
}
|
|
|
|
func TestExecute_CallInvalidArgs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
code string
|
|
}{
|
|
{"no arguments", `call()`},
|
|
{"one argument", `call("action")`},
|
|
{"args not object", `call("action", "not_an_object")`},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
caller := newMockCaller()
|
|
code := fmt.Sprintf(`
|
|
var res = %s;
|
|
({ ok: res.ok, code: res.error.code })
|
|
`, tt.code)
|
|
result, err := Execute(context.Background(), code, caller, ExecuteOptions{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
resultMap := result.Value.(map[string]any)
|
|
if resultMap["ok"] != false {
|
|
t.Errorf("expected ok=false, got %v", resultMap["ok"])
|
|
}
|
|
if resultMap["code"] != "INVALID_ARGS" {
|
|
t.Errorf("expected code='INVALID_ARGS', got %v", resultMap["code"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExecute_MaxCalls(t *testing.T) {
|
|
caller := newMockCaller()
|
|
caller.results["action"] = "ok"
|
|
|
|
code := `
|
|
var results = [];
|
|
for (var i = 0; i < 10; i++) {
|
|
var res = call("action", {});
|
|
results.push({ ok: res.ok, code: res.error ? res.error.code : null });
|
|
}
|
|
({ results: results, total: results.length })
|
|
`
|
|
result, err := Execute(context.Background(), code, caller, ExecuteOptions{
|
|
MaxCalls: 3,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Only 3 calls should have succeeded upstream
|
|
if len(caller.calls) != 3 {
|
|
t.Errorf("expected 3 upstream calls, got %d", len(caller.calls))
|
|
}
|
|
|
|
resultMap := result.Value.(map[string]any)
|
|
results := resultMap["results"].([]any)
|
|
|
|
// First 3 should be ok, rest should be MAX_CALLS_EXCEEDED
|
|
for i, r := range results {
|
|
rm := r.(map[string]any)
|
|
if i < 3 {
|
|
if rm["ok"] != true {
|
|
t.Errorf("call %d: expected ok=true, got %v", i, rm["ok"])
|
|
}
|
|
} else {
|
|
if rm["ok"] != false {
|
|
t.Errorf("call %d: expected ok=false, got %v", i, rm["ok"])
|
|
}
|
|
if rm["code"] != CodeMaxCallsExceeded {
|
|
t.Errorf("call %d: expected code=%q, got %v", i, CodeMaxCallsExceeded, rm["code"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExecute_MultipleCallsInLoop(t *testing.T) {
|
|
caller := newMockCaller()
|
|
caller.results["add"] = map[string]any{"sum": 10}
|
|
|
|
code := `
|
|
var total = 0;
|
|
for (var i = 0; i < 5; i++) {
|
|
var res = call("add", { a: i, b: 1 });
|
|
if (res.ok) total++;
|
|
}
|
|
({ total: total })
|
|
`
|
|
result, err := Execute(context.Background(), code, caller, ExecuteOptions{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(caller.calls) != 5 {
|
|
t.Fatalf("expected 5 calls, got %d", len(caller.calls))
|
|
}
|
|
if result.CallCount != 5 {
|
|
t.Errorf("expected CallCount=5, got %d", result.CallCount)
|
|
}
|
|
}
|
|
|
|
func TestExecute_SandboxBlockedAPIs(t *testing.T) {
|
|
blockedAPIs := []struct {
|
|
name string
|
|
code string
|
|
}{
|
|
{"require", `require("fs")`},
|
|
{"fetch", `fetch("http://example.com")`},
|
|
{"setTimeout", `setTimeout(function(){}, 100)`},
|
|
{"setInterval", `setInterval(function(){}, 100)`},
|
|
{"clearTimeout", `clearTimeout(1)`},
|
|
{"clearInterval", `clearInterval(1)`},
|
|
{"XMLHttpRequest", `new XMLHttpRequest()`},
|
|
{"process.env", `process.env.HOME`},
|
|
}
|
|
|
|
for _, tt := range blockedAPIs {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
caller := newMockCaller()
|
|
result, err := Execute(context.Background(), tt.code, caller, ExecuteOptions{})
|
|
|
|
// Should either error or return undefined (not execute the blocked API)
|
|
if err == nil && result.Ok() {
|
|
// If it succeeds, the value should be undefined/nil (the API was replaced with undefined)
|
|
// This is acceptable — the key thing is the API doesn't actually work
|
|
t.Logf("%s returned: %v (blocked by sandbox)", tt.name, result.Value)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Ok is a helper for test assertions.
|
|
func (r *ExecuteResult) Ok() bool {
|
|
return r != nil
|
|
}
|
|
|
|
func TestExecute_TypeScriptAutoDetect(t *testing.T) {
|
|
caller := newMockCaller()
|
|
code := `const x: number = 42; const msg: string = "hello"; ({ result: x, message: msg })`
|
|
|
|
result, err := Execute(context.Background(), code, caller, ExecuteOptions{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
resultMap, ok := result.Value.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected map, got %T", result.Value)
|
|
}
|
|
|
|
// goja exports numbers as int64 or float64
|
|
var num int64
|
|
switch v := resultMap["result"].(type) {
|
|
case int64:
|
|
num = v
|
|
case float64:
|
|
num = int64(v)
|
|
default:
|
|
t.Fatalf("expected numeric result, got %T", resultMap["result"])
|
|
}
|
|
if num != 42 {
|
|
t.Errorf("expected 42, got %d", num)
|
|
}
|
|
if resultMap["message"] != "hello" {
|
|
t.Errorf("expected 'hello', got %v", resultMap["message"])
|
|
}
|
|
}
|
|
|
|
func TestExecute_TypeScriptWithInterface(t *testing.T) {
|
|
caller := newMockCaller()
|
|
caller.results["get_data"] = map[string]any{"value": 99}
|
|
|
|
code := `
|
|
interface Result {
|
|
ok: boolean;
|
|
result?: any;
|
|
error?: any;
|
|
}
|
|
const res: Result = call("get_data", { key: "test" });
|
|
if (!res.ok) throw new Error("failed");
|
|
({ data: res.result })
|
|
`
|
|
result, err := Execute(context.Background(), code, caller, ExecuteOptions{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(caller.calls) != 1 {
|
|
t.Fatalf("expected 1 call, got %d", len(caller.calls))
|
|
}
|
|
|
|
resultMap := result.Value.(map[string]any)
|
|
data := resultMap["data"].(map[string]any)
|
|
// Values passed through the call() bridge preserve their Go types
|
|
var val int64
|
|
switch v := data["value"].(type) {
|
|
case int:
|
|
val = int64(v)
|
|
case int64:
|
|
val = v
|
|
case float64:
|
|
val = int64(v)
|
|
}
|
|
if val != 99 {
|
|
t.Errorf("expected value=99, got %v", data["value"])
|
|
}
|
|
}
|
|
|
|
func TestExecute_PlainJSNotTranspiled(t *testing.T) {
|
|
// Plain JS should work without transpilation
|
|
caller := newMockCaller()
|
|
code := `var x = 42; x`
|
|
|
|
result, err := Execute(context.Background(), code, caller, ExecuteOptions{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
var num int64
|
|
switch v := result.Value.(type) {
|
|
case int64:
|
|
num = v
|
|
case float64:
|
|
num = int64(v)
|
|
}
|
|
if num != 42 {
|
|
t.Errorf("expected 42, got %v", result.Value)
|
|
}
|
|
}
|
|
|
|
func TestExecute_Duration(t *testing.T) {
|
|
caller := newMockCaller()
|
|
result, err := Execute(context.Background(), `42`, caller, ExecuteOptions{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if result.Duration <= 0 {
|
|
t.Error("expected positive duration")
|
|
}
|
|
}
|
|
|
|
func TestExecute_ContextCancellation(t *testing.T) {
|
|
caller := newMockCaller()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel immediately
|
|
|
|
_, err := Execute(ctx, `while(true) {}`, caller, ExecuteOptions{
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error from cancelled context")
|
|
}
|
|
}
|
|
|
|
func TestExecError_Error(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err ExecError
|
|
contains string
|
|
}{
|
|
{
|
|
name: "without stack",
|
|
err: ExecError{Code: CodeSyntaxError, Message: "unexpected token"},
|
|
contains: "SYNTAX_ERROR: unexpected token",
|
|
},
|
|
{
|
|
name: "with stack",
|
|
err: ExecError{Code: CodeRuntimeError, Message: "boom", Stack: "at line 1"},
|
|
contains: "at line 1",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
msg := tt.err.Error()
|
|
if msg == "" {
|
|
t.Error("expected non-empty error message")
|
|
}
|
|
})
|
|
}
|
|
}
|