Ctrl K
ComunidadGuías…

pkg/jsplugin/engine_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package jsplugin

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"io"
	"math"
	"net/http"
	"net/url"
	"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 TestEngineFetchContract(t *testing.T) {
	engine, err := Compile(`
export function run(request) { return utils.fetch(request); }
export function fetchBytes(url) { return Array.from(new Uint8Array(utils.fetch({url: url, responseType: "bytes"}).body)); }
export function fetchFiveTimes() {
  const failures = [];
  for (let i = 0; i < 5; i++) {
    try { utils.fetch({url: "https://api.example/json"}); } catch (e) { failures.push(e.message); }
  }
  return failures;
}`, Options{Key: "fetch"})
	require.NoError(t, err)
	var requests []FetchRequest
	ctx := WithFetcher(t.Context(), func(_ context.Context, request FetchRequest) (FetchResponse, error) {
		requests = append(requests, request)
		switch request.URL {
		case "https://api.example/json":
			return FetchResponse{Status: http.StatusOK, Headers: map[string]string{"Content-Type": "application/json"}, Body: []byte(`{"file":{"id":7}}`)}, nil
		case "https://api.example/text":
			return FetchResponse{Status: http.StatusNotFound, Body: []byte("not found")}, nil
		case "https://api.example/bytes":
			return FetchResponse{Status: http.StatusPartialContent, Body: []byte{0, 0x80, 0xff, '{'}}, nil
		case "https://api.example/large":
			return FetchResponse{Status: http.StatusOK, Body: bytes.Repeat([]byte("a"), MaxFetchBodyBytes+1)}, nil
		case "https://api.example/down?token=secret":
			return FetchResponse{}, &url.Error{Op: "Get", URL: request.URL, Err: errors.New("connection refused")}
		}
		return FetchResponse{Status: http.StatusOK, Body: []byte(`{"ignored":true}`)}, nil
	})

	_, err = engine.Call(t.Context(), "run", map[string]any{"url": "https://api.example/json"})
	assert.ErrorContains(t, err, "utils.fetch is not available in run")

	value, err := engine.Call(ctx, "run", map[string]any{"url": "https://api.example/json", "headers": map[string]any{"Authorization": "Bearer key"}})
	require.NoError(t, err)
	assert.Equal(t, map[string]any{
		"status":  int64(http.StatusOK),
		"headers": map[string]any{"Content-Type": "application/json"},
		"body":    map[string]any{"file": map[string]any{"id": int64(7)}},
	}, value)
	assert.Equal(t, []FetchRequest{{Hook: "run", URL: "https://api.example/json", Method: http.MethodGet, Headers: map[string]string{"Authorization": "Bearer key"}}}, requests)

	value, err = engine.Call(ctx, "run", map[string]any{"url": "https://api.example/text"})
	require.NoError(t, err)
	assert.Equal(t, map[string]any{"status": int64(http.StatusNotFound), "headers": map[string]any{}, "body": "not found"}, value)

	value, err = engine.Call(ctx, "run", map[string]any{"url": "https://api.example/head", "method": "head"})
	require.NoError(t, err)
	assert.Equal(t, "", value.(map[string]any)["body"])
	assert.Equal(t, http.MethodHead, requests[len(requests)-1].Method)

	// Bytes that are neither JSON nor UTF-8 reach the plugin unchanged.
	value, err = engine.Call(ctx, "fetchBytes", "https://api.example/bytes")
	require.NoError(t, err)
	assert.Equal(t, []any{int64(0), int64(0x80), int64(0xff), int64('{')}, value)

	sent := len(requests)
	for name, request := range map[string]map[string]any{
		"unsupported member \"body\"":            {"url": "https://api.example/json", "body": "x"},
		"only GET and HEAD":                      {"url": "https://api.example/json", "method": "POST"},
		`responseType must be "json" or "bytes"`: {"url": "https://api.example/json", "responseType": "text"},
		"request header \"Host\" is not":         {"url": "https://api.example/json?token=secret", "headers": map[string]any{"Host": "other.example"}},
		"absolute http(s) URL":                   {"url": "/v1/files/retrieve"},
		"exceeds 65536 bytes":                    {"url": "https://api.example/" + strings.Repeat("a", 64<<10)},
	} {
		_, err = engine.Call(ctx, "run", request)
		require.Error(t, err, name)
		assert.Contains(t, err.Error(), name)
		assert.NotContains(t, err.Error(), "secret")
	}
	assert.Len(t, requests, sent, "rejected requests must not reach the fetcher")

	_, err = engine.Call(ctx, "run", map[string]any{"url": "https://api.example/large"})
	assert.ErrorContains(t, err, "utils.fetch response from api.example exceeds 1048576 bytes")

	_, err = engine.Call(ctx, "run", map[string]any{"url": "https://api.example/down?token=secret"})
	var hookErr *HookError
	require.ErrorAs(t, err, &hookErr)
	assert.Equal(t, "utils.fetch to api.example failed: connection refused", hookErr.Message)

	value, err = engine.Call(ctx, "fetchFiveTimes")
	require.NoError(t, err)
	assert.Equal(t, []any{"utils.fetch allows at most 4 requests per hook call"}, value)
}

func TestEngineFetchLendsItsExecutionSlot(t *testing.T) {
	engine, err := Compile(`
export function fetchOnce() { return utils.fetch({url: "https://api.example/slow"}).body; }
export function plain() { return 42; }`, Options{Key: "fetch-slot", Concurrency: 1})
	require.NoError(t, err)
	entered := make(chan struct{})
	release := make(chan struct{})
	ctx := WithFetcher(t.Context(), func(context.Context, FetchRequest) (FetchResponse, error) {
		close(entered)
		<-release
		return FetchResponse{Status: http.StatusOK, Body: []byte(`"done"`)}, nil
	})
	fetched := make(chan any, 1)
	go func() {
		value, err := engine.Call(ctx, "fetchOnce")
		assert.NoError(t, err)
		fetched <- value
	}()
	<-entered

	// The only slot is lent while the fetch waits; without that this call
	// would wait for its deadline.
	waitContext, cancel := context.WithTimeout(t.Context(), 5*time.Second)
	defer cancel()
	value, err := engine.Call(waitContext, "plain")
	require.NoError(t, err)
	assert.Equal(t, int64(42), value)

	close(release)
	assert.Equal(t, "done", <-fetched)
}

func TestEngineFetchWaitDoesNotCountTowardCallTimeout(t *testing.T) {
	engine, err := Compile(`export function run() { return utils.fetch({url: "https://api.example/slow"}).status; }`, Options{
		Key: "fetch-clock", Timeout: 20 * time.Millisecond,
	})
	require.NoError(t, err)
	entered := make(chan struct{})
	release := make(chan struct{})
	ctx := WithFetcher(t.Context(), func(context.Context, FetchRequest) (FetchResponse, error) {
		close(entered)
		<-release
		return FetchResponse{Status: http.StatusNoContent}, nil
	})
	result := make(chan error, 1)
	var value any
	go func() {
		var err error
		value, err = engine.Call(ctx, "run")
		result <- err
	}()
	<-entered
	// Five times the JavaScript timeout: the clock is stopped while waiting.
	time.Sleep(100 * time.Millisecond)
	close(release)
	require.NoError(t, <-result)
	assert.Equal(t, int64(http.StatusNoContent), value)
}

func TestEngineInterruptsAFetchThatCannotResume(t *testing.T) {
	engine, err := Compile(`
let calls = 0;
export function run(fetch) {
  calls++;
  if (!fetch) return calls;
  try { utils.fetch({url: "https://api.example/slow"}); } catch (e) { return "caught: " + e.message; }
  return "resumed";
}`, Options{Key: "fetch-resume", Concurrency: 1})
	require.NoError(t, err)
	entered := make(chan struct{})
	release := make(chan struct{})
	ctx, cancel := context.WithCancel(t.Context())
	defer cancel()
	ctx = WithFetcher(ctx, func(context.Context, FetchRequest) (FetchResponse, error) {
		close(entered)
		<-release
		return FetchResponse{Status: http.StatusOK}, nil
	})
	result := make(chan error, 1)
	go func() {
		_, err := engine.Call(ctx, "run", true)
		result <- err
	}()
	<-entered
	// Another call takes the lent slot and the caller leaves before the fetch
	// returns, so the slot cannot be taken back.
	engine.semaphore <- struct{}{}
	cancel()
	close(release)
	err = <-result
	<-engine.semaphore

	require.ErrorIs(t, err, context.Canceled)
	var hookErr *HookError
	assert.False(t, errors.As(err, &hookErr), "JavaScript must not catch a failed resume")
	value, err := engine.Call(t.Context(), "run", false)
	require.NoError(t, err)
	assert.Equal(t, int64(1), value, "an interrupted runtime must be discarded")
	assert.Empty(t, engine.semaphore)
	assert.Empty(t, engine.parked)
}

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())
		})
	}
}