From 214459d8282ffc2105794a79d2e00564396b77e3 Mon Sep 17 00:00:00 2001 From: buke Date: Fri, 22 May 2026 19:51:08 +0800 Subject: [PATCH 1/2] feat(value): add conversion and UTF-16 bindings - add native quickjs-ng wrappers for number, index, bigint, property-key, and UTF-16 conversion APIs - add Context.NewStringUTF16 and focused regression coverage for success and error paths - keep full package coverage at 100.0% --- context.go | 15 +++++ context_test.go | 37 ++++++++++- value.go | 101 +++++++++++++++++++++++++++++ value_test.go | 167 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 319 insertions(+), 1 deletion(-) diff --git a/context.go b/context.go index ad401bb..c698343 100644 --- a/context.go +++ b/context.go @@ -398,6 +398,21 @@ func (ctx *Context) NewString(v string) *Value { return &Value{ctx: ctx, ref: C.JS_NewStringLen(ctx.ref, ptr, C.size_t(len(v)))} } +// NewStringUTF16 returns a string value from UTF-16 code units. +func (ctx *Context) NewStringUTF16(v []uint16) *Value { + if !ctx.hasValidRef() { + return nil + } + + var ptr *C.uint16_t + if len(v) > 0 { + ptr = (*C.uint16_t)(unsafe.Pointer(&v[0])) + } + ref := C.JS_NewStringUTF16(ctx.ref, ptr, C.size_t(len(v))) + goruntime.KeepAlive(v) + return &Value{ctx: ctx, ref: ref} +} + // NewDate returns a JavaScript Date object from epoch milliseconds. func (ctx *Context) NewDate(epochMS float64) *Value { if !ctx.hasValidRef() { diff --git a/context_test.go b/context_test.go index 3a88ab7..57c81c1 100644 --- a/context_test.go +++ b/context_test.go @@ -104,7 +104,6 @@ func TestContextPostCloseContracts(t *testing.T) { require.NotNil(t, v) ctx.Close() - require.Nil(t, ctx.Runtime()) require.False(t, ctx.HasException()) require.Nil(t, ctx.Exception()) @@ -119,6 +118,42 @@ func TestContextPostCloseContracts(t *testing.T) { rt.Close() } +func TestContextNewStringUTF16(t *testing.T) { + useStableOwnerHooksForLegacySubtests(t) + + rt := NewRuntime() + defer rt.Close() + ctx := rt.NewContext() + defer ctx.Close() + + ascii := ctx.NewStringUTF16([]uint16{'o', 'k'}) + require.NotNil(t, ascii) + defer ascii.Free() + require.True(t, ascii.IsString()) + require.Equal(t, "ok", ascii.ToString()) + + empty := ctx.NewStringUTF16(nil) + require.NotNil(t, empty) + defer empty.Free() + require.Equal(t, "", empty.ToString()) + + wide := ctx.NewStringUTF16([]uint16{0xD800}) + require.NotNil(t, wide) + defer wide.Free() + utf16, err := wide.ToStringUTF16() + require.NoError(t, err) + require.Equal(t, []uint16{0xD800}, utf16) + + var nilCtx *Context + require.Nil(t, nilCtx.NewStringUTF16([]uint16{'x'})) + + closedRT := NewRuntime() + closedCtx := closedRT.NewContext() + closedCtx.Close() + require.Nil(t, closedCtx.NewStringUTF16([]uint16{'x'})) + closedRT.Close() +} + func TestContextInternalStateHelpers(t *testing.T) { var nilCtx *Context require.Nil(t, nilCtx.Runtime()) diff --git a/value.go b/value.go index 8efb243..6ed1ba5 100644 --- a/value.go +++ b/value.go @@ -231,6 +231,107 @@ func (v *Value) ToBigInt() *big.Int { return val } +// ToNumber returns the numeric value as a new JavaScript Number. +func (v *Value) ToNumber() (*Value, error) { + if !v.hasValidContext() { + return nil, errors.New("value context is not available") + } + + converted := &Value{ctx: v.ctx, ref: C.JS_ToNumber(v.ctx.ref, v.ref)} + if converted.IsException() { + return nil, v.ctx.Exception() + } + + return converted, nil +} + +// ToIndex returns the value converted to a JavaScript array index. +func (v *Value) ToIndex() (uint64, error) { + if !v.hasValidContext() { + return 0, errors.New("value context is not available") + } + + val := C.uint64_t(0) + if C.JS_ToIndex(v.ctx.ref, &val, v.ref) != 0 { + return 0, v.ctx.Exception() + } + + return uint64(val), nil +} + +// ToBigInt64 returns the value converted to int64 using BigInt semantics. +func (v *Value) ToBigInt64() (int64, error) { + if !v.hasValidContext() { + return 0, errors.New("value context is not available") + } + + val := C.int64_t(0) + if C.JS_ToBigInt64(v.ctx.ref, &val, v.ref) != 0 { + return 0, v.ctx.Exception() + } + + return int64(val), nil +} + +// ToBigUint64 returns the value converted to uint64 using BigInt semantics. +func (v *Value) ToBigUint64() (uint64, error) { + if !v.hasValidContext() { + return 0, errors.New("value context is not available") + } + + val := C.uint64_t(0) + if C.JS_ToBigUint64(v.ctx.ref, &val, v.ref) != 0 { + return 0, v.ctx.Exception() + } + + return uint64(val), nil +} + +// ToInt64Ext returns the value converted to int64, accepting BigInt inputs. +func (v *Value) ToInt64Ext() (int64, error) { + if !v.hasValidContext() { + return 0, errors.New("value context is not available") + } + + val := C.int64_t(0) + if C.JS_ToInt64Ext(v.ctx.ref, &val, v.ref) != 0 { + return 0, v.ctx.Exception() + } + + return int64(val), nil +} + +// ToPropertyKey returns the value converted to a JavaScript property key. +func (v *Value) ToPropertyKey() (*Value, error) { + if !v.hasValidContext() { + return nil, errors.New("value context is not available") + } + + converted := &Value{ctx: v.ctx, ref: C.JS_ToPropertyKey(v.ctx.ref, v.ref)} + if converted.IsException() { + return nil, v.ctx.Exception() + } + + return converted, nil +} + +// ToStringUTF16 returns the UTF-16 code units of the string representation of the value. +func (v *Value) ToStringUTF16() ([]uint16, error) { + if !v.hasValidContext() { + return nil, errors.New("value context is not available") + } + + length := C.size_t(0) + ptr := C.JS_ToCStringLenUTF16(v.ctx.ref, &length, v.ref) + if ptr == nil { + return nil, v.ctx.Exception() + } + defer C.JS_FreeCStringUTF16(v.ctx.ref, ptr) + + utf16 := unsafe.Slice((*uint16)(unsafe.Pointer(ptr)), int(length)) + return append([]uint16(nil), utf16...), nil +} + // Len returns the length of the array. func (v *Value) Len() int64 { length := v.Get("length") diff --git a/value_test.go b/value_test.go index b787b46..5236bdd 100644 --- a/value_test.go +++ b/value_test.go @@ -477,6 +477,173 @@ func TestValueConversions(t *testing.T) { require.Nil(t, normalIntVal.ToBigInt()) } +func TestValueNativeConversionAPIs(t *testing.T) { + useStableOwnerHooksForLegacySubtests(t) + + rt := NewRuntime() + defer rt.Close() + ctx := rt.NewContext() + defer ctx.Close() + + t.Run("ToNumber", func(t *testing.T) { + str := ctx.NewString("42.5") + defer str.Free() + + number, err := str.ToNumber() + require.NoError(t, err) + require.NotNil(t, number) + defer number.Free() + require.True(t, number.IsNumber()) + require.InDelta(t, 42.5, number.ToFloat64(), 0.00001) + + throwing := ctx.Eval(`({ valueOf() { throw new Error("num fail") }, toString() { return "1" } })`) + defer throwing.Free() + _, err = throwing.ToNumber() + require.Error(t, err) + require.Contains(t, err.Error(), "num fail") + + var nilValue *Value + converted, err := nilValue.ToNumber() + require.Nil(t, converted) + require.EqualError(t, err, "value context is not available") + }) + + t.Run("ToIndex", func(t *testing.T) { + str := ctx.NewString("7") + defer str.Free() + + index, err := str.ToIndex() + require.NoError(t, err) + require.Equal(t, uint64(7), index) + + negative := ctx.NewInt32(-1) + defer negative.Free() + _, err = negative.ToIndex() + require.Error(t, err) + + var nilValue *Value + _, err = nilValue.ToIndex() + require.EqualError(t, err, "value context is not available") + }) + + t.Run("ToBigInt64AndToBigUint64", func(t *testing.T) { + bigInt := ctx.NewBigInt64(-42) + defer bigInt.Free() + + int64Val, err := bigInt.ToBigInt64() + require.NoError(t, err) + require.Equal(t, int64(-42), int64Val) + + bigUint := ctx.NewBigUint64(^uint64(0)) + defer bigUint.Free() + + uint64Val, err := bigUint.ToBigUint64() + require.NoError(t, err) + require.Equal(t, ^uint64(0), uint64Val) + + number := ctx.NewInt32(7) + defer number.Free() + _, err = number.ToBigInt64() + require.Error(t, err) + _, err = number.ToBigUint64() + require.Error(t, err) + + var nilValue *Value + _, err = nilValue.ToBigInt64() + require.EqualError(t, err, "value context is not available") + _, err = nilValue.ToBigUint64() + require.EqualError(t, err, "value context is not available") + }) + + t.Run("ToInt64Ext", func(t *testing.T) { + number := ctx.NewInt32(33) + defer number.Free() + + int64Val, err := number.ToInt64Ext() + require.NoError(t, err) + require.Equal(t, int64(33), int64Val) + + bigInt := ctx.NewBigInt64(-9) + defer bigInt.Free() + int64Val, err = bigInt.ToInt64Ext() + require.NoError(t, err) + require.Equal(t, int64(-9), int64Val) + + symbol := ctx.NewSymbol("ext-fail") + defer symbol.Free() + _, err = symbol.ToInt64Ext() + require.Error(t, err) + + var nilValue *Value + _, err = nilValue.ToInt64Ext() + require.EqualError(t, err, "value context is not available") + }) + + t.Run("ToPropertyKey", func(t *testing.T) { + index := ctx.NewInt32(12) + defer index.Free() + + key, err := index.ToPropertyKey() + require.NoError(t, err) + require.NotNil(t, key) + defer key.Free() + require.True(t, key.IsString()) + require.Equal(t, "12", key.ToString()) + + symbol := ctx.NewSymbol("local-key") + defer symbol.Free() + symbolKey, err := symbol.ToPropertyKey() + require.NoError(t, err) + require.NotNil(t, symbolKey) + defer symbolKey.Free() + require.True(t, symbolKey.IsSymbol()) + require.True(t, symbol.SameValue(symbolKey)) + + throwing := ctx.Eval(`({ toString() { throw new Error("key fail") } })`) + defer throwing.Free() + _, err = throwing.ToPropertyKey() + require.Error(t, err) + require.Contains(t, err.Error(), "key fail") + + var nilValue *Value + converted, err := nilValue.ToPropertyKey() + require.Nil(t, converted) + require.EqualError(t, err, "value context is not available") + }) + + t.Run("ToStringUTF16", func(t *testing.T) { + ascii := ctx.NewString("ok") + defer ascii.Free() + + utf16, err := ascii.ToStringUTF16() + require.NoError(t, err) + require.Equal(t, []uint16{'o', 'k'}, utf16) + + wide := ctx.NewStringUTF16([]uint16{0xD800}) + defer wide.Free() + utf16, err = wide.ToStringUTF16() + require.NoError(t, err) + require.Equal(t, []uint16{0xD800}, utf16) + + number := ctx.NewInt32(42) + defer number.Free() + utf16, err = number.ToStringUTF16() + require.NoError(t, err) + require.Equal(t, []uint16{'4', '2'}, utf16) + + throwing := ctx.Eval(`({ toString() { throw new Error("utf16 fail") } })`) + defer throwing.Free() + _, err = throwing.ToStringUTF16() + require.Error(t, err) + require.Contains(t, err.Error(), "utf16 fail") + + var nilValue *Value + utf16, err = nilValue.ToStringUTF16() + require.Nil(t, utf16) + require.EqualError(t, err, "value context is not available") + }) +} + // TestValueJSON tests JSON operations func TestValueJSON(t *testing.T) { useStableOwnerHooksForLegacySubtests(t) From 9155e80bb6ecabd55799fd44d70067f6f8e1fa3a Mon Sep 17 00:00:00 2001 From: buke Date: Fri, 22 May 2026 20:32:56 +0800 Subject: [PATCH 2/2] fix(context): preserve non-Error JS exceptions - add a centralized fallback error when QuickJS throws a non-Error exception value - cover primitive throw paths in Context.Exception and the PR 3 conversion APIs - keep full package coverage at 100.0% --- context.go | 16 +++++++--- context_test.go | 17 ++++++++++ value_test.go | 83 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/context.go b/context.go index c698343..037eb5b 100644 --- a/context.go +++ b/context.go @@ -1,6 +1,7 @@ package quickjs import ( + "errors" "fmt" "os" goruntime "runtime" @@ -1247,14 +1248,21 @@ func (ctx *Context) HasException() bool { return bool(C.JS_HasException(ctx.ref)) } +func (ctx *Context) exceptionError() error { + val := &Value{ctx: ctx, ref: C.JS_GetException(ctx.ref)} + defer val.Free() + if err := val.Error(); err != nil { + return err + } + return errors.New("javascript exception") +} + // Exception returns a context's exception value. func (ctx *Context) Exception() error { - if !ctx.hasValidRef() { + if !ctx.hasValidRef() || !ctx.HasException() { return nil } - val := &Value{ctx: ctx, ref: C.JS_GetException(ctx.ref)} - defer val.Free() - return val.Error() + return ctx.exceptionError() } // Loop runs the context's event loop. diff --git a/context_test.go b/context_test.go index 57c81c1..8f086d7 100644 --- a/context_test.go +++ b/context_test.go @@ -154,6 +154,23 @@ func TestContextNewStringUTF16(t *testing.T) { closedRT.Close() } +func TestContextExceptionPrimitiveFallback(t *testing.T) { + useStableOwnerHooksForLegacySubtests(t) + + rt := NewRuntime() + defer rt.Close() + ctx := rt.NewContext() + defer ctx.Close() + + result := ctx.Eval(`throw 42`) + defer result.Free() + require.True(t, result.IsException()) + require.True(t, ctx.HasException()) + require.EqualError(t, ctx.Exception(), "javascript exception") + require.False(t, ctx.HasException()) + require.Nil(t, ctx.Exception()) +} + func TestContextInternalStateHelpers(t *testing.T) { var nilCtx *Context require.Nil(t, nilCtx.Runtime()) diff --git a/value_test.go b/value_test.go index 5236bdd..ca12c9f 100644 --- a/value_test.go +++ b/value_test.go @@ -642,6 +642,89 @@ func TestValueNativeConversionAPIs(t *testing.T) { require.Nil(t, utf16) require.EqualError(t, err, "value context is not available") }) + + t.Run("PrimitiveExceptionFallback", func(t *testing.T) { + tests := []struct { + name string + expr string + invoke func(*Value) error + }{ + { + name: "ToNumber", + expr: `({ valueOf() { throw 42 }, toString() { return "1" } })`, + invoke: func(v *Value) error { + converted, err := v.ToNumber() + if converted != nil { + converted.Free() + } + return err + }, + }, + { + name: "ToIndex", + expr: `({ valueOf() { throw 42 }, toString() { return "1" } })`, + invoke: func(v *Value) error { + _, err := v.ToIndex() + return err + }, + }, + { + name: "ToBigInt64", + expr: `({ valueOf() { throw 42 } })`, + invoke: func(v *Value) error { + _, err := v.ToBigInt64() + return err + }, + }, + { + name: "ToBigUint64", + expr: `({ valueOf() { throw 42 } })`, + invoke: func(v *Value) error { + _, err := v.ToBigUint64() + return err + }, + }, + { + name: "ToInt64Ext", + expr: `({ valueOf() { throw 42 } })`, + invoke: func(v *Value) error { + _, err := v.ToInt64Ext() + return err + }, + }, + { + name: "ToPropertyKey", + expr: `({ toString() { throw 42 } })`, + invoke: func(v *Value) error { + converted, err := v.ToPropertyKey() + if converted != nil { + converted.Free() + } + return err + }, + }, + { + name: "ToStringUTF16", + expr: `({ toString() { throw 42 } })`, + invoke: func(v *Value) error { + converted, err := v.ToStringUTF16() + if converted != nil { + return errors.New("unexpected utf16 result") + } + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + throwing := ctx.Eval(tt.expr) + defer throwing.Free() + err := tt.invoke(throwing) + require.EqualError(t, err, "javascript exception") + }) + } + }) } // TestValueJSON tests JSON operations