diff --git a/Jint.Tests/Runtime/InteropTests.cs b/Jint.Tests/Runtime/InteropTests.cs index 915928ddcf..9ccdfbada3 100644 --- a/Jint.Tests/Runtime/InteropTests.cs +++ b/Jint.Tests/Runtime/InteropTests.cs @@ -2318,6 +2318,134 @@ function throwException4(){ Assert.Throws(() => engine.Invoke("throwException4")); } + [Fact] + public void ShouldDecorateClrExceptionErrors() + { + var exceptionMessage = "Test exception"; + var decoratorCalled = false; + Exception capturedOriginalException = null; + + var engine = new Engine(options => + { + options.CatchClrExceptions(); + options.DecorateClrExceptionErrors((engine, error, clrException) => + { + decoratorCalled = true; + capturedOriginalException = clrException; + + // Add custom property + error.Set("clrType", clrException.GetType().FullName); + error.Set("customProperty", "decorated"); + + // Modify existing property + var currentMessage = error.Get("message").ToString(); + error.Set("message", $"[Decorated] {currentMessage}"); + }); + }); + + engine.SetValue("throwException", new Action(() => { throw new InvalidOperationException(exceptionMessage); })); + + var result = engine.Evaluate(@" + let caughtError; + try { + throwException(); + } catch(e) { + caughtError = e; + } + caughtError; + "); + + Assert.True(decoratorCalled, "Decorator should have been called"); + Assert.NotNull(capturedOriginalException); + Assert.IsType(capturedOriginalException); + Assert.Equal(exceptionMessage, capturedOriginalException.Message); + + var errorObject = result.AsObject(); + Assert.Equal("decorated", errorObject.Get("customProperty").AsString()); + Assert.Equal("System.InvalidOperationException", errorObject.Get("clrType").AsString()); + Assert.Equal($"[Decorated] {exceptionMessage}", errorObject.Get("message").AsString()); + } + + [Fact] + public void ShouldDecorateClrExceptionErrorsFromMemberCalls() + { + var decoratorCallCount = 0; + + var engine = new Engine(options => + { + options.AllowClr(); + options.CatchClrExceptions(); + options.DecorateClrExceptionErrors((engine, error, clrException) => + { + decoratorCallCount++; + error.Set("exceptionType", clrException.GetType().Name); + }); + }); + + engine.SetValue("instance", new MemberExceptionTest(false)); + + engine.Execute(@" + try { + instance.ThrowingFunction(); + } catch(e) { + if (e.exceptionType !== 'InvalidOperationException') { + throw new Error('Expected exceptionType to be InvalidOperationException'); + } + } + "); + + Assert.Equal(1, decoratorCallCount); + } + + [Fact] + public void ShouldNotCallDecoratorWhenExceptionNotCaught() + { + var decoratorCalled = false; + + var engine = new Engine(options => + { + options.CatchClrExceptions(e => e is NotSupportedException); + options.DecorateClrExceptionErrors((engine, error, clrException) => + { + decoratorCalled = true; + }); + }); + + engine.SetValue("throwException", new Action(() => { throw new InvalidOperationException(); })); + + // Should throw because InvalidOperationException is not caught + Assert.Throws(() => engine.Evaluate("throwException()")); + Assert.False(decoratorCalled, "Decorator should not be called when exception is not caught"); + } + + [Fact] + public void DecoratorCanAccessEngineContext() + { + var engine = new Engine(options => + { + options.CatchClrExceptions(); + options.DecorateClrExceptionErrors((engine, error, clrException) => + { + // Decorator can access engine and add context from it + error.Set("hasRealm", engine.Realm != null); + error.Set("timestamp", DateTime.UtcNow.ToString("o")); + }); + }); + + engine.SetValue("throwException", new Action(() => { throw new Exception("test"); })); + + var result = engine.Evaluate(@" + try { + throwException(); + } catch(e) { + return { hasRealm: e.hasRealm, hasTimestamp: e.timestamp !== undefined }; + } + ").AsObject(); + + Assert.True(result.Get("hasRealm").AsBoolean()); + Assert.True(result.Get("hasTimestamp").AsBoolean()); + } + [Fact] public void ArrayFromShouldConvertListToArrayLike() { diff --git a/Jint/Options.Extensions.cs b/Jint/Options.Extensions.cs index 226b75c06b..dade799fbe 100644 --- a/Jint/Options.Extensions.cs +++ b/Jint/Options.Extensions.cs @@ -207,6 +207,20 @@ public static Options CatchClrExceptions(this Options options, Options.Exception return options; } + /// + /// Sets a decorator function that is called after a JavaScript error object is created from a CLR exception. + /// The decorator can add custom properties, modify the error message, or enrich the error with additional context. + /// This is only called when is enabled and the exception is caught. + /// + /// The engine options. + /// A function that receives the engine, the created error object, and the original CLR exception. + /// The options instance for fluent configuration. + public static Options DecorateClrExceptionErrors(this Options options, Options.ClrExceptionErrorDecoratorDelegate decorator) + { + options.Interop.ClrExceptionErrorDecorator = decorator; + return options; + } + public static Options Constraint(this Options options, Constraint constraint) { if (constraint != null) diff --git a/Jint/Options.cs b/Jint/Options.cs index 963d377ee7..330b498e03 100644 --- a/Jint/Options.cs +++ b/Jint/Options.cs @@ -30,6 +30,8 @@ public class Options public delegate bool ExceptionHandlerDelegate(Exception exception); + public delegate void ClrExceptionErrorDecoratorDelegate(Engine engine, ObjectInstance error, Exception exception); + public delegate string? BuildCallStackDelegate(string shortDescription, SourceLocation location, string[]? arguments); public delegate string SerializeToJsonDelegate(object? target, string space, string? currentIndent); @@ -338,6 +340,13 @@ public class InteropOptions /// public ExceptionHandlerDelegate ExceptionHandler { get; set; } = _defaultExceptionHandler; + /// + /// Called after a JavaScript error object is created from a CLR exception (when returns true). + /// Allows decorating the error object with additional properties or modifying its state. + /// The decorator receives the engine instance, the created error object, and the original CLR exception. + /// + public ClrExceptionErrorDecoratorDelegate? ClrExceptionErrorDecorator { get; set; } + /// /// Assemblies to allow scripts to call CLR types directly like System.IO.File. /// diff --git a/Jint/Runtime/Interop/ClrFunction.cs b/Jint/Runtime/Interop/ClrFunction.cs index f21303eaeb..c1f9de2b62 100644 --- a/Jint/Runtime/Interop/ClrFunction.cs +++ b/Jint/Runtime/Interop/ClrFunction.cs @@ -46,7 +46,7 @@ private JsValue CallSlow(JsValue thisObject, JsCallArguments arguments) { if (_engine.Options.Interop.ExceptionHandler(e)) { - Throw.JavaScriptException(_realm.Intrinsics.Error, e.Message); + Throw.FromClrException(_engine, e); } else { diff --git a/Jint/Runtime/Throw.cs b/Jint/Runtime/Throw.cs index 55ab309b99..07ecf7088b 100644 --- a/Jint/Runtime/Throw.cs +++ b/Jint/Runtime/Throw.cs @@ -168,6 +168,17 @@ public static void JavaScriptException(ErrorConstructor errorConstructor, string throw new JavaScriptException(errorConstructor, message); } + /// + /// Creates and throws a JavaScript exception from a CLR exception, applying any configured decorator. + /// + [DoesNotReturn] + public static void FromClrException(Engine engine, Exception clrException) + { + var error = engine.Realm.Intrinsics.Error.Construct(clrException.Message); + engine.Options.Interop.ClrExceptionErrorDecorator?.Invoke(engine, error, clrException); + throw new JavaScriptException(error); + } + [DoesNotReturn] public static void RecursionDepthOverflowException(JintCallStack currentStack) { @@ -188,7 +199,7 @@ public static void MeaningfulException(Engine engine, TargetInvocationException if (engine.Options.Interop.ExceptionHandler(meaningfulException)) { - Error(engine, meaningfulException.Message); + FromClrException(engine, meaningfulException); } ExceptionDispatchInfo.Capture(meaningfulException).Throw();