Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions Jint.Tests/Runtime/InteropTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2318,6 +2318,134 @@ function throwException4(){
Assert.Throws<ArgumentNullException>(() => 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<InvalidOperationException>(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<InvalidOperationException>(() => 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()
{
Expand Down
14 changes: 14 additions & 0 deletions Jint/Options.Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,20 @@ public static Options CatchClrExceptions(this Options options, Options.Exception
return options;
}

/// <summary>
/// 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 <see cref="CatchClrExceptions(Options)"/> is enabled and the exception is caught.
/// </summary>
/// <param name="options">The engine options.</param>
/// <param name="decorator">A function that receives the engine, the created error object, and the original CLR exception.</param>
/// <returns>The options instance for fluent configuration.</returns>
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)
Expand Down
9 changes: 9 additions & 0 deletions Jint/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -338,6 +340,13 @@ public class InteropOptions
/// </summary>
public ExceptionHandlerDelegate ExceptionHandler { get; set; } = _defaultExceptionHandler;

/// <summary>
/// Called after a JavaScript error object is created from a CLR exception (when <see cref="ExceptionHandler"/> 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.
/// </summary>
public ClrExceptionErrorDecoratorDelegate? ClrExceptionErrorDecorator { get; set; }

/// <summary>
/// Assemblies to allow scripts to call CLR types directly like <example>System.IO.File</example>.
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion Jint/Runtime/Interop/ClrFunction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
13 changes: 12 additions & 1 deletion Jint/Runtime/Throw.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,17 @@ public static void JavaScriptException(ErrorConstructor errorConstructor, string
throw new JavaScriptException(errorConstructor, message);
}

/// <summary>
/// Creates and throws a JavaScript exception from a CLR exception, applying any configured decorator.
/// </summary>
[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)
{
Expand All @@ -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();
Expand Down