diff --git a/remote/webdriver-bidi/RemoteValue.jsm b/remote/webdriver-bidi/RemoteValue.jsm
index a966882d099ef..dc8350686465b 100644
--- a/remote/webdriver-bidi/RemoteValue.jsm
+++ b/remote/webdriver-bidi/RemoteValue.jsm
@@ -4,7 +4,7 @@
"use strict";
-var EXPORTED_SYMBOLS = ["deserialize", "serialize"];
+var EXPORTED_SYMBOLS = ["deserialize", "serialize", "stringify"];
const { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
@@ -418,8 +418,32 @@ function serialize(
}
lazy.logger.warn(
- `Unsupported type: ${type} for remote value: ${value.toString()}`
+ `Unsupported type: ${type} for remote value: ${stringify(value)}`
);
return undefined;
}
+
+/**
+ * Safely stringify a value.
+ *
+ * @param {Object} value
+ * Value of any type to be stringified.
+ *
+ * @returns {String} String representation of the value.
+ */
+function stringify(obj) {
+ let text;
+ try {
+ text =
+ obj !== null && typeof obj === "object" ? obj.toString() : String(obj);
+ } catch (e) {
+ // The error-case will also be handled in `finally {}`.
+ } finally {
+ if (typeof text != "string") {
+ text = Object.prototype.toString.apply(obj);
+ }
+ }
+
+ return text;
+}
diff --git a/remote/webdriver-bidi/modules/windowglobal/script.jsm b/remote/webdriver-bidi/modules/windowglobal/script.jsm
index 2b15682fec543..b3e1b57fb0594 100644
--- a/remote/webdriver-bidi/modules/windowglobal/script.jsm
+++ b/remote/webdriver-bidi/modules/windowglobal/script.jsm
@@ -23,6 +23,7 @@ XPCOMUtils.defineLazyModuleGetters(lazy, {
getFramesFromStack: "chrome://remote/content/shared/Stack.jsm",
isChromeFrame: "chrome://remote/content/shared/Stack.jsm",
serialize: "chrome://remote/content/webdriver-bidi/RemoteValue.jsm",
+ stringify: "chrome://remote/content/webdriver-bidi/RemoteValue.jsm",
});
XPCOMUtils.defineLazyGetter(lazy, "dbg", () => {
@@ -88,10 +89,7 @@ class ScriptModule extends Module {
exception: lazy.serialize(exception, 1),
lineNumber: stack.line - 1,
stackTrace: { callFrames },
- text:
- typeof exception === "object"
- ? exception.toString()
- : String(exception),
+ text: lazy.stringify(exception),
};
}
diff --git a/remote/webdriver-bidi/test/xpcshell/test_RemoteValue.js b/remote/webdriver-bidi/test/xpcshell/test_RemoteValue.js
index b7775d8940d8f..c607636a04128 100644
--- a/remote/webdriver-bidi/test/xpcshell/test_RemoteValue.js
+++ b/remote/webdriver-bidi/test/xpcshell/test_RemoteValue.js
@@ -223,7 +223,7 @@ const REMOTE_COMPLEX_VALUES = [
},
];
-const { deserialize, serialize } = ChromeUtils.import(
+const { deserialize, serialize, stringify } = ChromeUtils.import(
"chrome://remote/content/webdriver-bidi/RemoteValue.jsm"
);
@@ -557,3 +557,41 @@ add_test(function test_serializeRemoteComplexValues() {
run_next_test();
});
+
+add_test(function test_stringify() {
+ const STRINGIFY_TEST_CASES = [
+ [undefined, "undefined"],
+ [null, "null"],
+ ["foobar", "foobar"],
+ ["2", "2"],
+ [-0, "0"],
+ [Infinity, "Infinity"],
+ [-Infinity, "-Infinity"],
+ [3, "3"],
+ [1.4, "1.4"],
+ [true, "true"],
+ [42n, "42"],
+ [{ toString: () => "bar" }, "bar", "toString: () => 'bar'"],
+ [{ toString: () => 4 }, "[object Object]", "toString: () => 4"],
+ [{ toString: undefined }, "[object Object]", "toString: undefined"],
+ [{ toString: null }, "[object Object]", "toString: null"],
+ [
+ {
+ toString: () => {
+ throw new Error("toString error");
+ },
+ },
+ "[object Object]",
+ "toString: () => { throw new Error('toString error'); }",
+ ],
+ ];
+
+ for (const [value, expectedString, description] of STRINGIFY_TEST_CASES) {
+ info(`Checking '${description || value}'`);
+ const stringifiedValue = stringify(value);
+
+ Assert.strictEqual(expectedString, stringifiedValue, "Got expected string");
+ }
+
+ run_next_test();
+});
diff --git a/testing/web-platform/mozilla/tests/webdriver/bidi/script/exception_details.py b/testing/web-platform/mozilla/tests/webdriver/bidi/script/exception_details.py
new file mode 100644
index 0000000000000..af1cf48290b11
--- /dev/null
+++ b/testing/web-platform/mozilla/tests/webdriver/bidi/script/exception_details.py
@@ -0,0 +1,70 @@
+import pytest
+
+from webdriver.bidi.modules.script import ContextTarget, ScriptEvaluateResultException
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("await_promise", [True, False])
+@pytest.mark.parametrize(
+ "expression",
+ [
+ "null",
+ "{ toString: 'not a function' }",
+ "{ toString: () => {{ throw 'toString not allowed'; }} }",
+ "{ toString: () => true }",
+ ],
+)
+@pytest.mark.asyncio
+async def test_call_function_without_to_string_interface(
+ bidi_session, top_context, await_promise, expression
+):
+ function_declaration = "()=>{throw { toString: 'not a function' } }"
+ if await_promise:
+ function_declaration = "async" + function_declaration
+
+ with pytest.raises(ScriptEvaluateResultException) as exception:
+ await bidi_session.script.call_function(
+ function_declaration=function_declaration,
+ await_promise=await_promise,
+ target=ContextTarget(top_context["context"]),
+ )
+
+ assert "exceptionDetails" in exception.value.result
+ exceptionDetails = exception.value.result["exceptionDetails"]
+
+ assert "text" in exceptionDetails
+ assert isinstance(exceptionDetails["text"], str)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("await_promise", [True, False])
+@pytest.mark.parametrize(
+ "expression",
+ [
+ "null",
+ "{ toString: 'not a function' }",
+ "{ toString: () => {{ throw 'toString not allowed'; }} }",
+ "{ toString: () => true }",
+ ],
+)
+@pytest.mark.asyncio
+async def test_evaluate_without_to_string_interface(
+ bidi_session, top_context, await_promise, expression
+):
+ if await_promise:
+ expression = f"Promise.reject({expression})"
+ else:
+ expression = f"throw {expression}"
+
+ with pytest.raises(ScriptEvaluateResultException) as exception:
+ await bidi_session.script.evaluate(
+ expression=expression,
+ await_promise=await_promise,
+ target=ContextTarget(top_context["context"]),
+ )
+
+ assert "exceptionDetails" in exception.value.result
+ exceptionDetails = exception.value.result["exceptionDetails"]
+
+ assert "text" in exceptionDetails
+ assert isinstance(exceptionDetails["text"], str)
diff --git a/testing/web-platform/tests/webdriver/tests/bidi/conftest.py b/testing/web-platform/tests/webdriver/tests/bidi/conftest.py
index 459ea0d958941..cba694b4b881c 100644
--- a/testing/web-platform/tests/webdriver/tests/bidi/conftest.py
+++ b/testing/web-platform/tests/webdriver/tests/bidi/conftest.py
@@ -34,9 +34,3 @@ def test_page_cross_origin_frame(inline, test_page_cross_origin):
@pytest.fixture
def test_page_same_origin_frame(inline, test_page):
return inline(f"")
-
-
-@pytest.fixture
-async def top_context(bidi_session):
- contexts = await bidi_session.browsing_context.get_tree()
- return contexts[0]
diff --git a/testing/web-platform/tests/webdriver/tests/support/fixtures.py b/testing/web-platform/tests/webdriver/tests/support/fixtures.py
index 30f93d4cea0fb..e63c0d01d269d 100644
--- a/testing/web-platform/tests/webdriver/tests/support/fixtures.py
+++ b/testing/web-platform/tests/webdriver/tests/support/fixtures.py
@@ -248,3 +248,9 @@ def iframe(src, **kwargs):
return "".format(inline(src, **kwargs))
return iframe
+
+
+@pytest.fixture
+async def top_context(bidi_session):
+ contexts = await bidi_session.browsing_context.get_tree()
+ return contexts[0]