Files
new-api/pkg/jsplugin/engine_test.go
T
CaIon 2ffc595901 feat(jsplugin): run task plugins on moejs
Task plugins now run on github.com/Calcium-Ion/moejs v0.1.0-alpha.3
instead of Sobek, and the host utils are ported to it. The plugin API is
unchanged. Code compiled from strings is no longer allowed: eval and the
Function constructors throw an EvalError.

Hooks receive host values without defensive copies. The engine converts
an object or array when the hook first reads it and never writes
JavaScript changes back to the host value. Stored protocol state and task
views are passed as raw JSON and parsed inside the runtime instead of
being decoded into Go maps first. An idle runtime drops its references to
the last call's arguments.

Whether an optional hook is exported is decided once when the plugin
loads, so checking for it no longer takes an execution slot.

Usage validation returns the normalized facts in a new map instead of
writing into the hook result, so a hook that returns the request body no
longer changes it. The request descriptor is copied instead of being
round-tripped through JSON.
2026-09-30 17:24:19 +08:00

822 lines
30 KiB
Go

package jsplugin
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"math"
"os"
"os/exec"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"unsafe"
"weak"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/gin-gonic/gin"
jsoniter "github.com/json-iterator/go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEngineCallsESMExportWithInjectedUtils(t *testing.T) {
t.Parallel()
logs := make([]string, 0, 1)
engine, err := Compile(`
export function sign(ctx) {
console.log("called", ctx.name);
return {
now: utils.unixNow(),
digest: utils.hmacSHA256(ctx.message, ctx.secret),
encoded: utils.base64(ctx.message),
};
}
export const meta = { apiVersion: 1, key: "mock" };
`, Options{
Key: "mock", Version: "1.0.0",
Now: func() time.Time { return time.Unix(1234, 0) },
Log: func(message string) { logs = append(logs, message) },
})
require.NoError(t, err)
result, err := engine.Call(context.Background(), "sign", map[string]any{
"name": "fixture", "message": "hello", "secret": "secret",
})
require.NoError(t, err)
assert.Equal(t, map[string]any{
"now": int64(1234), "digest": "88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b", "encoded": "aGVsbG8=",
}, result)
assert.Equal(t, []string{"[plugin:mock@1.0.0] called fixture"}, logs)
meta, err := engine.Export(context.Background(), "meta")
require.NoError(t, err)
assert.Equal(t, map[string]any{"apiVersion": int64(1), "key": "mock"}, meta)
}
func TestEngineJSONCloneProducesIndependentNativeContainers(t *testing.T) {
engine, err := Compile(`
export function clone(input) {
const result = utils.json.clone(input);
result.items.push({text:"appended"});
result.items[0].text = "changed";
return {result, array:Array.isArray(result.items),
prototype:Object.getPrototypeOf(result) === Object.prototype,
ownProto:Object.prototype.hasOwnProperty.call(result,"__proto__"),
available:utils.hasCapability("json-clone@1"), unavailable:utils.hasCapability("json-clone@2")};
}
export function invalid(kind) {
let value;
switch(kind) {
case "cycle": value={}; value.self=value; break;
case "function": value={run:function(){}}; break;
case "undefined": value={missing:undefined}; break;
case "nan": value=NaN; break;
case "bigint": value=1n; break;
case "date": value=new Date(); break;
case "sparse": value=[,1]; break;
case "size": value="<".repeat(200000); break;
case "nodes": value=new Array(32768).fill(0); break;
}
return utils.json.clone(value);
}`, Options{})
require.NoError(t, err)
input := map[string]any{"items": []any{map[string]any{"text": "original"}}, "__proto__": map[string]any{"marker": "data"}, "zero": math.Copysign(0, -1)}
value, err := engine.Call(t.Context(), "clone", input)
require.NoError(t, err)
result := value.(map[string]any)
assert.Equal(t, true, result["array"])
assert.Equal(t, true, result["prototype"])
assert.Equal(t, true, result["ownProto"])
assert.Equal(t, true, result["available"])
assert.Equal(t, false, result["unavailable"])
cloned := result["result"].(map[string]any)
assert.Equal(t, []any{map[string]any{"text": "changed"}, map[string]any{"text": "appended"}}, cloned["items"])
assert.Equal(t, map[string]any{"marker": "data"}, cloned["__proto__"])
assert.True(t, math.Signbit(cloned["zero"].(float64)))
assert.Equal(t, []any{map[string]any{"text": "original"}}, input["items"])
for _, kind := range []string{"cycle", "function", "undefined", "nan", "bigint", "date", "sparse", "size", "nodes"} {
t.Run(kind, func(t *testing.T) {
_, err := engine.Call(t.Context(), "invalid", kind)
require.Error(t, err)
})
}
}
func TestEngineRawJSONArgumentsMatchDecodedArguments(t *testing.T) {
engine, err := Compile(`
export function describe(...values) {
const seen = [];
const stack = values.reverse().map((v) => ({v}));
while (stack.length > 0) {
const {v, key, close} = stack.pop();
if (close) {
seen.push(close);
continue;
}
if (key !== undefined) seen.push(key);
if (v === null || typeof v !== "object") {
seen.push(typeof v + ":" + (Object.is(v, -0) ? "-0" : JSON.stringify(v)));
continue;
}
const array = Array.isArray(v);
seen.push(array ? "[" : Object.getPrototypeOf(v) === Object.prototype ? "{" : "{?");
stack.push({close: array ? "]" : "}"});
const keys = Object.keys(v);
for (let i = keys.length - 1; i >= 0; i--) {
stack.push({v: v[keys[i]], key: array ? undefined : JSON.stringify(keys[i])});
}
}
return seen.join(" ");
}`, Options{})
require.NoError(t, err)
// Hosts pass text written by common.Marshal, where both paths agree on
// key order, numbers and strings.
values := map[string]any{
"object": map[string]any{
"__proto__": map[string]any{"polluted": true},
"10": "ten", "2": "two", "a": []any{}, "b": map[string]any{},
"numbers": []any{0, math.Copysign(0, -1), 1e21, int64(123456789012345678), 1.5e-7, -12, math.MaxInt64, math.SmallestNonzeroFloat64},
"strings": []any{"", "<>&

", "é中😀", "\u0000\t\"\\", "a\xffb"},
"nested": map[string]any{"z": nil, "y": []any{true, false, map[string]any{"x": []any{[]any{1}}}}},
},
"array": []any{map[string]any{"id": "x", "b": 1, "a": 2}, nil, "s"},
"string": "plain",
"number": 42.5,
"null": nil,
"deep": json.RawMessage(strings.Repeat("[", 600) + strings.Repeat("]", 600)),
}
for name, value := range values {
t.Run(name, func(t *testing.T) {
text, err := common.Marshal(value)
require.NoError(t, err)
var decoded any
require.NoError(t, common.Unmarshal(text, &decoded))
want, err := engine.Call(t.Context(), "describe", decoded)
require.NoError(t, err)
got, err := engine.Call(t.Context(), "describe", RawJSON(text))
require.NoError(t, err)
assert.Equal(t, want, got)
nested, err := engine.Call(t.Context(), "describe", map[string]any{"value": RawJSON(text)})
require.NoError(t, err)
wantNested, err := engine.Call(t.Context(), "describe", map[string]any{"value": decoded})
require.NoError(t, err)
assert.Equal(t, wantNested, nested)
})
}
_, err = engine.Call(t.Context(), "describe", "first", RawJSON(`{"a":`))
require.EqualError(t, err, "plugin @ hook describe argument 2: unexpected end of JSON input")
}
func TestJSONStateChangesAndEncodedLimits(t *testing.T) {
const changes = `[
[{"op":"set","path":[],"value":{"text":"<","parts":[],"nested":{"value":null}}}],
[{"op":"appendText","path":["text"],"value":"&\n\"图像😀\u2028"},
{"op":"append","path":["parts"],"value":{"text":"a","enabled":false}},
{"op":"set","path":["nested","value"],"value":[1,2]}],
[{"op":"appendText","path":["parts",0,"text"],"value":"b"},
{"op":"set","path":["nested","value",0],"value":0},
{"op":"set","path":["nested","a<b"],"value":{}}]
]`
var frames []any
require.NoError(t, common.UnmarshalJsonStr(changes, &frames))
const expected = `{"text":"<&\n\"图像😀\u2028","parts":[{"text":"ab","enabled":false}],"nested":{"value":[0,2],"a<b":{}}}`
var want any
require.NoError(t, common.UnmarshalJsonStr(expected, &want))
encoded, err := common.Marshal(want)
require.NoError(t, err)
state := NewJSONState(len(encoded))
for _, frame := range frames {
require.NoError(t, state.Apply(t.Context(), frame))
_, err = state.Value()
require.NoError(t, err, "every intermediate frame must match the codec's actual byte size")
}
value, err := state.Value()
require.NoError(t, err)
assert.Equal(t, want, value)
// One extra byte fails at the update boundary and invalidates publication.
require.Error(t, state.Apply(t.Context(), []any{map[string]any{"op": "appendText", "path": []any{"text"}, "value": "x"}}))
_, err = state.Value()
require.Error(t, err)
t.Run("replacement releases byte and node budget", func(t *testing.T) {
state := NewJSONState(16)
for _, value := range []any{"abcdefghijklmn", map[string]any{}, []any{int64(0), false, nil}, []any(nil), map[string]any(nil)} {
require.NoError(t, state.Apply(t.Context(), []any{map[string]any{"op": "set", "path": []any{}, "value": value}}))
_, err := state.Value()
require.NoError(t, err)
}
})
t.Run("encoded limit includes HTML escaping", func(t *testing.T) {
state := NewJSONState(13)
require.NoError(t, state.Apply(t.Context(), []any{map[string]any{"op": "set", "path": []any{}, "value": "<"}}))
require.Error(t, state.Apply(t.Context(), []any{map[string]any{"op": "appendText", "path": []any{}, "value": "<"}}))
})
t.Run("request cancellation", func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
state := NewJSONState(64)
require.ErrorIs(t, state.Apply(ctx, []any{}), context.Canceled)
})
t.Run("bounded operation and result complexity", func(t *testing.T) {
many := make([]any, 257)
for index := range many {
many[index] = map[string]any{"op": "set", "path": []any{}, "value": nil}
}
require.Error(t, NewJSONState(1024).Apply(t.Context(), many))
var deep any = nil
for range 33 {
deep = []any{deep}
}
for _, value := range []any{deep, make([]any, 32768), math.Inf(1)} {
state := NewJSONState(1 << 20)
require.Error(t, state.Apply(t.Context(), []any{map[string]any{"op": "set", "path": []any{}, "value": value}}))
}
})
for _, invalid := range []string{
`[{"op":"remove","path":[],"value":null}]`,
`[{"op":"set","path":[],"value":null,"extra":true}]`,
`[{"op":"set","path":["missing","child"],"value":1}]`,
`[{"op":"set","path":["parts",-1],"value":1}]`,
`[{"op":"set","path":["parts",0.5],"value":1}]`,
`[{"op":"set","path":["parts",100000000000000000000],"value":1}]`,
`[{"op":"appendText","path":["parts"],"value":"x"}]`,
`[{"op":"append","path":["text"],"value":"x"}]`,
} {
t.Run(invalid, func(t *testing.T) {
state := NewJSONState(1024)
require.NoError(t, state.Apply(t.Context(), frames[0]))
var changes any
require.NoError(t, common.UnmarshalJsonStr(invalid, &changes))
require.Error(t, state.Apply(t.Context(), changes))
_, err := state.Value()
require.Error(t, err)
})
}
}
type bytePreservingJSONCodec struct {
jsoniter.API
}
func (c bytePreservingJSONCodec) Decode(reader io.Reader, value any) error {
return c.NewDecoder(reader).Decode(value)
}
func TestJSONStateUTF8Normalization(t *testing.T) {
const codecEnv = "JSPLUGIN_TEST_BYTE_PRESERVING_JSON"
if os.Getenv(codecEnv) == "1" {
// Only this test runs in the child process; the parent's codec and
// parallel tests are unaffected. Disabling HTML escaping in jsoniter
// also preserves invalid UTF-8, independently of Sonic's platform support.
kitutil.SetCodec(bytePreservingJSONCodec{jsoniter.Config{SortMapKeys: true}.Froze()})
encoded, err := common.Marshal("\xff\xfe")
require.NoError(t, err)
require.Equal(t, []byte("\"\xff\xfe\""), encoded)
var decoded any
require.NoError(t, common.Unmarshal(encoded, &decoded))
require.Equal(t, "\xff\xfe", decoded)
} else {
t.Run("byte preserving codec", func(t *testing.T) {
executable, err := os.Executable()
require.NoError(t, err)
command := exec.CommandContext(t.Context(), executable, "-test.run=^TestJSONStateUTF8Normalization$", "-test.v")
command.Env = append(os.Environ(), codecEnv+"=1")
output, err := command.CombinedOutput()
require.NoError(t, err, "%s", output)
})
}
const raw = "图像\xff\xfe😀<&\n\"\\\u2028"
const normalized = "图像\ufffd\ufffd😀<&\n\"\\\u2028"
for _, tc := range []struct {
name string
op string
path []any
initial any
value any
want any
}{
{"set", "set", []any{}, false, raw, normalized},
{"append", "append", []any{}, []any{float64(0), false}, raw, []any{float64(0), false, normalized}},
{"appendText", "appendText", []any{"a<b"}, map[string]any{"a<b": "前\xff"}, raw, map[string]any{"a<b": "前\ufffd" + normalized}},
{"separate invalid bytes", "appendText", []any{}, "\xff", "\xfe", "\ufffd\ufffd"},
} {
t.Run(tc.name, func(t *testing.T) {
encoded, err := common.Marshal(tc.want)
require.NoError(t, err)
for _, shortBy := range []int{0, 1} {
state := NewJSONState(len(encoded) - shortBy)
err := state.Apply(t.Context(), []any{
map[string]any{"op": "set", "path": []any{}, "value": tc.initial},
map[string]any{"op": tc.op, "path": tc.path, "value": tc.value},
})
if shortBy == 0 {
require.NoError(t, err, "normalized JSON must fit exactly")
value, err := state.Value()
require.NoError(t, err)
assert.Equal(t, tc.want, value)
continue
}
require.Error(t, err, "normalized JSON must exceed the limit by one byte")
value, valueErr := state.Value()
require.ErrorIs(t, valueErr, err)
assert.Nil(t, value, "a failed batch must not publish its successful prefix")
require.ErrorIs(t, state.Apply(t.Context(), []any{
map[string]any{"op": "set", "path": []any{}, "value": nil},
}), err, "a later replacement must not revive failed state")
}
})
}
t.Run("clone", func(t *testing.T) {
engine, err := Compile(`export function clone(input) { return utils.json.clone(input); }`, Options{})
require.NoError(t, err)
input := map[string]any{"text": raw, "items": []any{float64(0), false, raw}}
value, err := engine.Call(t.Context(), "clone", input)
require.NoError(t, err)
assert.Equal(t, map[string]any{"text": normalized, "items": []any{int64(0), false, normalized}}, value)
assert.Equal(t, raw, input["text"])
encoded, err := common.Marshal(normalized)
require.NoError(t, err)
padding := strings.Repeat("x", MaxJSONToolBytes-len(encoded))
value, err = engine.Call(t.Context(), "clone", raw+padding)
require.NoError(t, err, "normalized clone must fit exactly")
assert.Equal(t, normalized, strings.TrimSuffix(value.(string), padding))
value, err = engine.Call(t.Context(), "clone", raw+padding+"x")
require.Error(t, err, "normalized clone must exceed the limit by one byte")
assert.Nil(t, value)
})
t.Run("invalid keys and paths remain rejected", func(t *testing.T) {
for _, change := range []any{
map[string]any{"op": "set", "path": []any{}, "value": map[string]any{"\xff\xfe": raw}},
map[string]any{"op": "set", "path": []any{"\xff\xfe"}, "value": raw},
} {
state := NewJSONState(128)
require.Error(t, state.Apply(t.Context(), []any{
map[string]any{"op": "set", "path": []any{}, "value": map[string]any{"\ufffd\ufffd": "original"}},
change,
}))
value, err := state.Value()
require.Error(t, err)
assert.Nil(t, value)
}
})
}
func TestEngineConsoleLogUsesDebugLoggerAndRequestContext(t *testing.T) {
previousDebug := common.DebugEnabled
common.DebugEnabled = false
t.Cleanup(func() { common.DebugEnabled = previousDebug })
var output bytes.Buffer
common.LogWriterMu.Lock()
previousWriter := gin.DefaultErrorWriter
gin.DefaultErrorWriter = &output
common.LogWriterMu.Unlock()
t.Cleanup(func() {
common.LogWriterMu.Lock()
gin.DefaultErrorWriter = previousWriter
common.LogWriterMu.Unlock()
})
plugin, err := CompilePlugin(`
export const meta = {
apiVersion: 1,
key: "console-debug",
name: "Console debug",
version: "1.2.3",
author: {name: "Test"},
models: ["debug-model"],
fetchMode: "per_task",
};
export function run(label) {
console.log("checkpoint", label);
return true;
}
export function buildSubmitRequest() { return {url: "https://example.com"}; }
export function parseSubmitResponse() { return {taskId: "one"}; }
export function buildQueryRequest() { return {url: "https://example.com"}; }
export function parseTaskResult() { return {status: "SUCCESS"}; }
`, Options{})
require.NoError(t, err)
engine := plugin.Engine
disabledContext := context.WithValue(context.Background(), common.RequestIdKey, "plugin-console-disabled")
_, err = engine.Call(disabledContext, "run", "disabled")
require.NoError(t, err)
assert.Empty(t, output.String())
common.DebugEnabled = true
contextA := context.WithValue(context.Background(), common.RequestIdKey, "plugin-console-request-a")
_, err = engine.Call(contextA, "run", "context-a")
require.NoError(t, err)
logA := output.String()
output.Reset()
contextB := context.WithValue(context.Background(), common.RequestIdKey, "plugin-console-request-b")
_, err = engine.Call(contextB, "run", "context-b")
require.NoError(t, err)
logB := output.String()
output.Reset()
_, err = engine.Call(context.Background(), "run", "background")
require.NoError(t, err)
logBackground := output.String()
assert.Contains(t, logA, "plugin-console-request-a")
assert.NotContains(t, logA, "plugin-console-request-b")
assert.Contains(t, logA, "task_plugin subsystem=runtime event=console")
assert.Contains(t, logA, "[plugin:console-debug@1.2.3] checkpoint context-a")
assert.NotContains(t, logA, "disabled")
assert.Contains(t, logB, "plugin-console-request-b")
assert.NotContains(t, logB, "plugin-console-request-a")
assert.Contains(t, logB, "[plugin:console-debug@1.2.3] checkpoint context-b")
assert.Contains(t, logBackground, "| SYSTEM |")
assert.NotContains(t, logBackground, "plugin-console-request-a")
assert.NotContains(t, logBackground, "plugin-console-request-b")
assert.Contains(t, logBackground, "[plugin:console-debug@1.2.3] checkpoint background")
}
func TestCompileRejectsAsynchronousAndImportedPlugins(t *testing.T) {
t.Parallel()
for name, source := range map[string]string{
"async": `export async function run() {}`,
"static import": `import value from "dependency"; export function run() { return value; }`,
"dynamic import": `export function run() { return import("dependency"); }`,
"top-level await": `const value = await work(); export function run() { return value; }`,
"export from": `export { value as run } from "dependency";`,
"export star": `export * from "dependency"; export function run() {}`,
} {
t.Run(name, func(t *testing.T) {
_, err := Compile(source, Options{Key: "invalid"})
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported plugin syntax")
})
}
_, err := Compile(`export function run() { return "import async await"; }`, Options{Key: "valid"})
require.NoError(t, err)
for name, body := range map[string]string{
"eval": `return eval("import('dependency')");`,
"Function constructor": `return Function("return async function () {}")();`,
} {
engine, err := Compile(`export function run() { `+body+` }`, Options{Key: "dynamic"})
require.NoError(t, err, name)
_, err = engine.Call(context.Background(), "run")
assert.ErrorContains(t, err, "EvalError", name)
}
_, err = Compile(`eval("1"); export function run() {}`, Options{Key: "dynamic"})
assert.ErrorContains(t, err, "EvalError")
}
func TestCompileIgnoresSourceMapDirectives(t *testing.T) {
t.Parallel()
// A sourceMappingURL comment must stay inert. Sobek's default loader
// os.ReadFiles the referenced server path during Compile and turns any
// load failure into a compile error, so an unresolvable path compiling
// cleanly proves the loader is disabled.
engine, err := Compile("export function run() { return 1; }\n//# sourceMappingURL=/nonexistent/leak-probe.map\n", Options{Key: "sourcemap"})
require.NoError(t, err)
value, err := engine.Call(context.Background(), "run")
require.NoError(t, err)
assert.Equal(t, int64(1), value)
}
func TestEngineInterruptsLongRunningHook(t *testing.T) {
t.Parallel()
engine, err := Compile(`let calls = 0; export function run(loop) { calls++; if (loop) { while (true) {} } return calls; }`, Options{
Key: "loop", Version: "1", Timeout: 20 * time.Millisecond,
})
require.NoError(t, err)
_, err = engine.Call(context.Background(), "run", true)
require.Error(t, err)
assert.Contains(t, err.Error(), "timed out")
var hookErr *HookError
assert.False(t, errors.As(err, &hookErr), "timeouts must not be HookError")
value, err := engine.Call(context.Background(), "run", false)
require.NoError(t, err)
assert.Equal(t, int64(1), value, "an interrupted runtime must be discarded")
}
func TestEngineDoesNotReinitializeIdleRuntimeAfterGC(t *testing.T) {
var initialized atomic.Int64
engine, err := Compile(`console.log("initialized"); export function run(){return 42;}`, Options{
Log: func(string) { initialized.Add(1) },
})
require.NoError(t, err)
runtime.GC()
runtime.GC()
value, err := engine.Call(t.Context(), "run")
require.NoError(t, err)
assert.Equal(t, int64(42), value)
assert.Equal(t, int64(1), initialized.Load())
}
func TestEngineIdleRuntimeReleasesLastCallArguments(t *testing.T) {
engine, err := Compile(`export function run(body){return body.image.length;}`, Options{Concurrency: 1})
require.NoError(t, err)
image := strings.Repeat("A", 1<<20)
released := weak.Make(unsafe.StringData(image))
value, err := engine.Call(t.Context(), "run", map[string]any{"image": image})
require.NoError(t, err)
assert.Equal(t, int64(1<<20), value)
runtime.GC()
runtime.GC()
assert.Nil(t, released.Value(), "an idle pooled runtime must not keep the last request body alive")
value, err = engine.Call(t.Context(), "run", map[string]any{"image": "AB"})
require.NoError(t, err)
assert.Equal(t, int64(2), value)
}
func TestEngineBoundsConcurrentCallsAndCancelsAnOccupiedRuntime(t *testing.T) {
entered := make(chan struct{}, 2)
release := make(chan struct{})
defer close(release)
engine, err := Compile(`export function run(loop) { utils.unixNow(); if (loop) { while (true) {} } return 42; }`, Options{
Concurrency: 2,
Now: func() time.Time {
entered <- struct{}{}
<-release
return time.Unix(1, 0)
},
})
require.NoError(t, err)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
canceled := make(chan error, 1)
healthy := make(chan error, 1)
go func() { _, err := engine.Call(ctx, "run", true); canceled <- err }()
go func() { _, err := engine.Call(t.Context(), "run", false); healthy <- err }()
<-entered
<-entered
_, err = engine.CallPathWithAdmissionTimeout(t.Context(), time.Nanosecond, "run", nil, false)
require.ErrorIs(t, err, ErrCallAdmissionTimeout)
cancel()
release <- struct{}{}
release <- struct{}{}
require.ErrorIs(t, <-canceled, context.Canceled)
require.NoError(t, <-healthy)
}
func TestEnginePreservesMutableExportAndMemberBindings(t *testing.T) {
engine, err := Compile(`
export let run = function(){return 1;};
export const native = {render:function(){return 1;}};
export function replace(){run=function(){return 2;};native.render=function(){return 3;};}
`, Options{Concurrency: 1})
require.NoError(t, err)
value, err := engine.Call(t.Context(), "run")
require.NoError(t, err)
assert.Equal(t, int64(1), value)
_, err = engine.Call(t.Context(), "replace")
require.NoError(t, err)
value, err = engine.Call(t.Context(), "run")
require.NoError(t, err)
assert.Equal(t, int64(2), value)
value, err = engine.CallMember(t.Context(), "native", "render")
require.NoError(t, err)
assert.Equal(t, int64(3), value)
}
func TestEngineHasExportUsesBindingsAsLoaded(t *testing.T) {
engine, err := Compile(`
export let later;
export const unset = undefined;
export function run(){return 1;}
export function assign(){later=function(){return 2;};}
`, Options{Concurrency: 1})
require.NoError(t, err)
engine.semaphore <- struct{}{}
assert.True(t, engine.HasExport("run"))
assert.False(t, engine.HasExport("later"))
assert.False(t, engine.HasExport("unset"))
assert.False(t, engine.HasExport("missing"))
<-engine.semaphore
_, err = engine.Call(t.Context(), "assign")
require.NoError(t, err)
assert.False(t, engine.HasExport("later"))
value, err := engine.Call(t.Context(), "later")
require.NoError(t, err)
assert.Equal(t, int64(2), value)
}
func TestEngineHookErrorExtractsSanitizedJSMessage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
source string
wantMessage string
wantLen int
}{
{
name: "Error object",
source: `export function run() { throw new Error("model is required"); }`,
wantMessage: "model is required",
},
{
name: "raw string throw",
source: `export function run() { throw "raw string"; }`,
wantMessage: "raw string",
},
{
name: "truncates to 512 runes",
source: `export function run() { throw new Error("x".repeat(2000)); }`,
wantLen: 512,
wantMessage: strings.Repeat("x", 512),
},
{
name: "scrubs control characters",
source: "export function run() { throw new Error(\"line1\\nline2\\x1b[31mred\"); }",
wantMessage: "line1 line2 [31mred",
},
{
name: "throwing message getter falls back without crashing",
source: `export function run() { throw {get message() { throw {get message() { return "deep"; }}; }}; }`,
wantMessage: "plugin hook failed",
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
engine, err := Compile(testCase.source, Options{Key: "diag", Version: "1.0.0"})
require.NoError(t, err)
_, err = engine.Call(context.Background(), "run")
require.Error(t, err)
var hookErr *HookError
require.True(t, errors.As(err, &hookErr))
assert.Equal(t, "run", hookErr.Hook)
assert.Equal(t, testCase.wantMessage, hookErr.Message)
if testCase.wantLen > 0 {
assert.Equal(t, testCase.wantLen, utf8.RuneCountInString(hookErr.Message))
}
assert.Contains(t, hookErr.Error(), "plugin diag@1.0.0")
assert.NotContains(t, hookErr.Message, "Error:")
assert.NotContains(t, hookErr.Message, "plugin diag@")
})
}
}
func TestEngineReportsProtocolAdmissionTimeoutSeparately(t *testing.T) {
engine, err := Compile(`
export const protocols = {
responses: {renderEvents: function() { return {events: [], done: false}; }},
};
`, Options{Key: "admission", Version: "1", Concurrency: 1})
require.NoError(t, err)
engine.semaphore <- struct{}{}
_, err = engine.CallPathWithAdmissionTimeout(
context.Background(),
time.Nanosecond,
"protocols",
[]string{"responses", "renderEvents"},
)
<-engine.semaphore
require.ErrorIs(t, err, ErrCallAdmissionTimeout)
result, err := engine.CallPathWithAdmissionTimeout(
context.Background(),
time.Second,
"protocols",
[]string{"responses", "renderEvents"},
)
require.NoError(t, err)
assert.Equal(t, map[string]any{"events": []any{}, "done": false}, result)
}
func TestEngineExportInterruptsLongRunningGetter(t *testing.T) {
t.Parallel()
engine, err := Compile(`
export const meta = {
apiVersion: 1,
get name() { while (true) {} },
};
`, Options{Key: "meta-loop", Version: "1.0.0", Timeout: 20 * time.Millisecond})
require.NoError(t, err)
_, err = engine.Export(context.Background(), "meta")
require.ErrorContains(t, err, "export meta interrupted")
}
func TestEngineExportReturnsThrownGetterError(t *testing.T) {
t.Parallel()
engine, err := Compile(`
export const meta = {
apiVersion: 1,
get name() { throw new Error("getter failed"); },
};
`, Options{Key: "meta-throw", Version: "1.0.0"})
require.NoError(t, err)
_, err = engine.Export(context.Background(), "meta")
require.ErrorContains(t, err, "export meta failed")
assert.Contains(t, err.Error(), "getter failed")
}
func TestEngineNestedHooksRequireOwnProperties(t *testing.T) {
t.Parallel()
engine, err := Compile(`
const inheritedRenderers = {
inherited: function(value) { return value; },
constructor: function(value) { return value; },
toString: function(value) { return value; },
["__proto__"]: function(value) { return value; },
};
export const renderers = Object.create(inheritedRenderers);
renderers.own = function(value) { return {id: value.id}; };
const inheritedProtocol = {
renderFinal: function(value) { return value; },
};
export const protocols = {
responses: Object.create(inheritedProtocol),
};
`, Options{Key: "own-hooks", Version: "1.0.0"})
require.NoError(t, err)
found, err := engine.HasCallablePath(context.Background(), "renderers", "own")
require.NoError(t, err)
assert.True(t, found)
result, err := engine.CallMember(context.Background(), "renderers", "own", map[string]any{"id": "task-1"})
require.NoError(t, err)
assert.Equal(t, map[string]any{"id": "task-1"}, result)
for _, member := range []string{"inherited", "constructor", "toString", "__proto__"} {
t.Run(member, func(t *testing.T) {
found, err := engine.HasCallablePath(context.Background(), "renderers", member)
require.NoError(t, err)
assert.False(t, found)
_, err = engine.CallMember(context.Background(), "renderers", member)
require.ErrorContains(t, err, "not found")
})
}
found, err = engine.HasCallablePath(context.Background(), "protocols", "responses", "renderFinal")
require.NoError(t, err)
assert.False(t, found)
_, err = engine.CallPath(context.Background(), "protocols", []string{"responses", "renderFinal"})
require.ErrorContains(t, err, "not found")
}
func TestCompileInterruptsLongRunningInitialization(t *testing.T) {
t.Parallel()
_, err := Compile(`while (true) {}; export function run() {}`, Options{
Key: "loop", Version: "1", Timeout: 20 * time.Millisecond,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "initialization timed out")
}
func TestValidateRequestURL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
requestURL string
baseURL string
allowedHosts []string
wantError string
}{
{name: "same host", requestURL: "https://api.example.com/v1/task", baseURL: "https://api.example.com/v1"},
{name: "default port", requestURL: "https://api.example.com:443/v1/task", baseURL: "https://api.example.com"},
{name: "approved host", requestURL: "https://upload.example.com/task", baseURL: "https://api.example.com", allowedHosts: []string{"upload.example.com"}},
{name: "approved host with port", requestURL: "http://192.168.1.10:8080/task", baseURL: "http://192.168.1.10:8000", allowedHosts: []string{"192.168.1.10:8080"}},
{name: "approved host with explicit default port", requestURL: "https://upload.example.com/task", baseURL: "https://api.example.com", allowedHosts: []string{"upload.example.com:443"}},
{name: "approved host port mismatch", requestURL: "http://192.168.1.10:9000/task", baseURL: "http://192.168.1.10:8000", allowedHosts: []string{"192.168.1.10:8080"}, wantError: "not allowed"},
{name: "subdomain is not implicit", requestURL: "https://evil.api.example.com/task", baseURL: "https://api.example.com", wantError: "not allowed"},
{name: "userinfo trick", requestURL: "https://api.example.com@evil.example/task", baseURL: "https://api.example.com", wantError: "not allowed"},
{name: "relative URL", requestURL: "/v1/task", baseURL: "https://api.example.com", wantError: "absolute"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := ValidateRequestURL(test.requestURL, test.baseURL, test.allowedHosts)
if test.wantError == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), test.wantError), err.Error())
})
}
}