diff --git a/capi/include/rustwright.h b/capi/include/rustwright.h index daa0b20..971a823 100644 --- a/capi/include/rustwright.h +++ b/capi/include/rustwright.h @@ -32,6 +32,18 @@ void rw_string_free(char *s); */ void rw_bytes_free(uint8_t *buf, size_t len); +/** + * Decode the core evaluate wire format into plain caller-owned JSON UTF-8. + * + * Array and object wrappers are removed, repeated non-cyclic references are + * duplicated, and references that form cycles become + * `{"__rustwright_cdp_cycle__": true}`. Leaf scalar tags are preserved for + * binding-specific native-value mapping. On success, free `*out_json` with + * rw_string_free. On failure, `*out_json` is NULL and rw_last_error describes + * the error. + */ +int32_t rw_decode_wire(const char *wire_json, char **out_json); + /** * Discover Chromium and return its executable path. * diff --git a/capi/src/lib.rs b/capi/src/lib.rs index b13f2e9..a2df3ff 100644 --- a/capi/src/lib.rs +++ b/capi/src/lib.rs @@ -210,6 +210,26 @@ pub unsafe extern "C" fn rw_bytes_free(buffer: *mut u8, len: usize) { } } +/// Decodes the core evaluate wire format into caller-owned plain JSON. +#[no_mangle] +pub unsafe extern "C" fn rw_decode_wire( + wire_json: *const c_char, + out_json: *mut *mut c_char, +) -> c_int { + ffi_status(|| { + if out_json.is_null() { + return Err("out_json must not be NULL".to_string()); + } + // SAFETY: Validated above; initialize before any fallible work. + unsafe { *out_json = ptr::null_mut() }; + let wire_json = unsafe { required_str(wire_json, "wire_json")? }; + let decoded = rw::decode_wire_value(wire_json).map_err(|error| error.to_string())?; + // SAFETY: Validated above. + unsafe { *out_json = owned_string(decoded)? }; + Ok(()) + }) +} + #[no_mangle] pub unsafe extern "C" fn rw_chromium_executable_path(out_path: *mut *mut c_char) -> c_int { ffi_status(|| { @@ -534,3 +554,31 @@ pub unsafe extern "C" fn rw_page_free(page: *mut RwPage) { )); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_wire_round_trip_uses_c_string_ownership() { + let wire = CString::new( + r#"{"__rustwright_cdp_array__":1,"items":[{"value":true},{"__rustwright_cdp_ref__":1}]}"#, + ) + .unwrap(); + let mut out = ptr::null_mut(); + + let status = unsafe { rw_decode_wire(wire.as_ptr(), &mut out) }; + + assert_eq!(status, 0); + assert!(!out.is_null()); + let decoded = unsafe { CStr::from_ptr(out) }.to_str().unwrap(); + assert_eq!( + serde_json::from_str::(decoded).unwrap(), + serde_json::json!([ + {"value": true}, + {"__rustwright_cdp_cycle__": true}, + ]) + ); + unsafe { rw_string_free(out) }; + } +} diff --git a/go/evaluate.go b/go/evaluate.go index 9525232..c718650 100644 --- a/go/evaluate.go +++ b/go/evaluate.go @@ -32,23 +32,27 @@ func (e JavaScriptError) Error() string { } func decodeEvaluateJSON(data []byte) (any, error) { + native, err := currentWireDecodeNative() + if err != nil { + return nil, err + } + data, err = native.decodeWireJSON(data) + if err != nil { + return nil, err + } var raw any if err := json.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("decode evaluate JSON: %w", err) } - return (&wireDecoder{refs: make(map[string]any)}).decode(raw) -} - -type wireDecoder struct { - refs map[string]any + return mapEvaluateLeaves(raw) } -func (d *wireDecoder) decode(value any) (any, error) { +func mapEvaluateLeaves(value any) (any, error) { switch value := value.(type) { case []any: decoded := make([]any, len(value)) for i := range value { - item, err := d.decode(value[i]) + item, err := mapEvaluateLeaves(value[i]) if err != nil { return nil, err } @@ -56,52 +60,13 @@ func (d *wireDecoder) decode(value any) (any, error) { } return decoded, nil case map[string]any: - return d.decodeObject(value) + return mapEvaluateObjectLeaves(value) default: return value, nil } } -func (d *wireDecoder) decodeObject(value map[string]any) (any, error) { - if id, ok := value["__rustwright_cdp_ref__"]; ok { - resolved, exists := d.refs[refKey(id)] - if !exists { - return nil, fmt.Errorf("decode evaluate JSON: unknown reference %v", id) - } - return resolved, nil - } - if id, ok := value["__rustwright_cdp_array__"]; ok { - items, ok := value["items"].([]any) - if !ok { - return nil, fmt.Errorf("decode evaluate JSON: array wrapper has invalid items") - } - decoded := make([]any, len(items)) - d.refs[refKey(id)] = decoded - for i := range items { - item, err := d.decode(items[i]) - if err != nil { - return nil, err - } - decoded[i] = item - } - return decoded, nil - } - if id, ok := value["__rustwright_cdp_object__"]; ok { - entries, ok := value["entries"].(map[string]any) - if !ok { - return nil, fmt.Errorf("decode evaluate JSON: object wrapper has invalid entries") - } - decoded := make(map[string]any, len(entries)) - d.refs[refKey(id)] = decoded - for key, entry := range entries { - item, err := d.decode(entry) - if err != nil { - return nil, err - } - decoded[key] = item - } - return decoded, nil - } +func mapEvaluateObjectLeaves(value map[string]any) (any, error) { if number, ok := value["__rustwright_cdp_unserializable_value__"].(string); ok { switch number { case "NaN": @@ -147,7 +112,7 @@ func (d *wireDecoder) decodeObject(value map[string]any) (any, error) { decoded := make(map[string]any, len(value)) for key, entry := range value { - item, err := d.decode(entry) + item, err := mapEvaluateLeaves(entry) if err != nil { return nil, err } @@ -155,7 +120,3 @@ func (d *wireDecoder) decodeObject(value map[string]any) (any, error) { } return decoded, nil } - -func refKey(value any) string { - return fmt.Sprintf("%v", value) -} diff --git a/go/evaluate_test.go b/go/evaluate_test.go index 67dbab7..e913410 100644 --- a/go/evaluate_test.go +++ b/go/evaluate_test.go @@ -2,40 +2,191 @@ package rustwright import ( "math" + "net/url" + "os" + "path/filepath" "reflect" + "runtime" "testing" + "time" ) -func TestDecodeEvaluateJSONWrappers(t *testing.T) { +func loadWireDecodeLibrary(t *testing.T) { + t.Helper() + path := os.Getenv("RUSTWRIGHT_LIB") + if path == "" { + name := "librustwright_capi.so" + if runtime.GOOS == "darwin" { + name = "librustwright_capi.dylib" + } + path = filepath.Join("..", "target", "release", name) + } + if _, err := loadNative(path); err != nil { + t.Fatalf("load wire decoder library %q: %v", path, err) + } +} + +func TestDecodeEvaluateJSONWrappersReferencesAndCycles(t *testing.T) { + loadWireDecodeLibrary(t) decoded, err := decodeEvaluateJSON([]byte(`{ "__rustwright_cdp_object__": 1, "entries": { "items": {"__rustwright_cdp_array__": 2, "items": [1, {"nested": true}]}, - "again": {"__rustwright_cdp_ref__": 2} + "again": {"__rustwright_cdp_ref__": 2}, + "self": {"__rustwright_cdp_ref__": 1} } }`)) if err != nil { t.Fatal(err) } - object := decoded.(map[string]any) - want := []any{float64(1), map[string]any{"nested": true}} - if !reflect.DeepEqual(object["items"], want) || !reflect.DeepEqual(object["again"], want) { - t.Fatalf("decoded wrappers = %#v", decoded) + wantItems := []any{float64(1), map[string]any{"nested": true}} + want := map[string]any{ + "items": wantItems, + "again": wantItems, + "self": map[string]any{"__rustwright_cdp_cycle__": true}, + } + if !reflect.DeepEqual(decoded, want) { + t.Fatalf("decoded wrappers = %#v, want %#v", decoded, want) } } -func TestDecodeEvaluateJSONTags(t *testing.T) { - decoded, err := decodeEvaluateJSON([]byte(`[ - {"__rustwright_cdp_undefined__": true}, - {"__rustwright_cdp_symbol__": true}, - {"__rustwright_cdp_function__": true}, - {"__rustwright_cdp_unserializable_value__": "NaN"} - ]`)) +func TestDecodeEvaluateJSONLeafRepresentations(t *testing.T) { + loadWireDecodeLibrary(t) + parsedURL, err := url.Parse("https://example.com/path?q=1") if err != nil { t.Fatal(err) } - values := decoded.([]any) - if values[0] != nil || values[1] != nil || values[2] != nil || !math.IsNaN(values[3].(float64)) { - t.Fatalf("decoded tags = %#v", values) + parsedDate := time.Date(2026, time.July, 21, 12, 34, 56, 789000000, time.UTC) + + tests := []struct { + name string + wire string + check func(*testing.T, any) + }{ + { + name: "undefined", + wire: `{"__rustwright_cdp_undefined__":true}`, + check: func(t *testing.T, got any) { + if got != nil { + t.Fatalf("got %#v, want nil", got) + } + }, + }, + { + name: "symbol", + wire: `{"__rustwright_cdp_symbol__":true}`, + check: func(t *testing.T, got any) { + if got != nil { + t.Fatalf("got %#v, want nil", got) + } + }, + }, + { + name: "function", + wire: `{"__rustwright_cdp_function__":true}`, + check: func(t *testing.T, got any) { + if got != nil { + t.Fatalf("got %#v, want nil", got) + } + }, + }, + { + name: "NaN", + wire: `{"__rustwright_cdp_unserializable_value__":"NaN"}`, + check: func(t *testing.T, got any) { + value, ok := got.(float64) + if !ok || !math.IsNaN(value) { + t.Fatalf("got %#v, want math.NaN()", got) + } + }, + }, + { + name: "positive infinity", + wire: `{"__rustwright_cdp_unserializable_value__":"Infinity"}`, + check: func(t *testing.T, got any) { + value, ok := got.(float64) + if !ok || !math.IsInf(value, 1) { + t.Fatalf("got %#v, want math.Inf(1)", got) + } + }, + }, + { + name: "negative infinity", + wire: `{"__rustwright_cdp_unserializable_value__":"-Infinity"}`, + check: func(t *testing.T, got any) { + value, ok := got.(float64) + if !ok || !math.IsInf(value, -1) { + t.Fatalf("got %#v, want math.Inf(-1)", got) + } + }, + }, + { + name: "negative zero wrapper preserved", + wire: `{"__rustwright_cdp_unserializable_value__":"-0"}`, + check: func(t *testing.T, got any) { + want := map[string]any{"__rustwright_cdp_unserializable_value__": "-0"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } + }, + }, + { + name: "bigint wrapper preserved", + wire: `{"__rustwright_cdp_unserializable_value__":"123n"}`, + check: func(t *testing.T, got any) { + want := map[string]any{"__rustwright_cdp_unserializable_value__": "123n"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } + }, + }, + { + name: "date", + wire: `{"__rustwright_cdp_date__":"2026-07-21T12:34:56.789Z"}`, + check: func(t *testing.T, got any) { + if !reflect.DeepEqual(got, parsedDate) { + t.Fatalf("got %#v, want %#v", got, parsedDate) + } + }, + }, + { + name: "URL", + wire: `{"__rustwright_cdp_url__":"https://example.com/path?q=1"}`, + check: func(t *testing.T, got any) { + if !reflect.DeepEqual(got, parsedURL) { + t.Fatalf("got %#v, want %#v", got, parsedURL) + } + }, + }, + { + name: "regexp p and f payload", + wire: `{"__rustwright_cdp_regexp__":{"p":"a+b","f":"gi"}}`, + check: func(t *testing.T, got any) { + want := RegExpValue{Pattern: "a+b", Flags: "gi"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } + }, + }, + { + name: "error", + wire: `{"__rustwright_cdp_error__":{"name":"TypeError","message":"broken","stack":"trace"}}`, + check: func(t *testing.T, got any) { + want := JavaScriptError{Name: "TypeError", Message: "broken", Stack: "trace"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := decodeEvaluateJSON([]byte(test.wire)) + if err != nil { + t.Fatal(err) + } + test.check(t, got) + }) } } diff --git a/go/ffi.go b/go/ffi.go index 3cbb788..2527745 100644 --- a/go/ffi.go +++ b/go/ffi.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "runtime" + "sync" "unsafe" "github.com/ebitengine/purego" @@ -17,6 +18,7 @@ type nativeAPI struct { lastError func() uintptr stringFree func(uintptr) bytesFree func(uintptr, uintptr) + decodeWire func(*byte, *uintptr) int32 chromiumExecutablePath func(*uintptr) int32 chromiumLaunch func(*byte, *uintptr) int32 browserNewPage func(uintptr, *uintptr) int32 @@ -35,6 +37,11 @@ type nativeAPI struct { pageFree func(uintptr) } +var wireDecodeNative struct { + sync.RWMutex + native *nativeAPI +} + func loadNative(path string) (_ *nativeAPI, err error) { if path == "" { return nil, errors.New("rustwright: shared library path is empty") @@ -55,6 +62,7 @@ func loadNative(path string) (_ *nativeAPI, err error) { purego.RegisterLibFunc(&n.lastError, h, "rw_last_error") purego.RegisterLibFunc(&n.stringFree, h, "rw_string_free") purego.RegisterLibFunc(&n.bytesFree, h, "rw_bytes_free") + purego.RegisterLibFunc(&n.decodeWire, h, "rw_decode_wire") purego.RegisterLibFunc(&n.chromiumExecutablePath, h, "rw_chromium_executable_path") purego.RegisterLibFunc(&n.chromiumLaunch, h, "rw_chromium_launch") purego.RegisterLibFunc(&n.browserNewPage, h, "rw_browser_new_page") @@ -71,9 +79,43 @@ func loadNative(path string) (_ *nativeAPI, err error) { purego.RegisterLibFunc(&n.pageScreenshot, h, "rw_page_screenshot") purego.RegisterLibFunc(&n.pageClose, h, "rw_page_close") purego.RegisterLibFunc(&n.pageFree, h, "rw_page_free") + wireDecodeNative.Lock() + wireDecodeNative.native = n + wireDecodeNative.Unlock() return n, nil } +func currentWireDecodeNative() (*nativeAPI, error) { + wireDecodeNative.RLock() + native := wireDecodeNative.native + wireDecodeNative.RUnlock() + if native == nil { + return nil, errors.New("rustwright: no native library is loaded for wire decoding") + } + return native, nil +} + +func (n *nativeAPI) decodeWireJSON(data []byte) ([]byte, error) { + wireBuf, wirePtr, err := cString(string(data)) + if err != nil { + return nil, err + } + var out uintptr + err = n.onOSThread(func() int32 { + return n.decodeWire(wirePtr, &out) + }) + runtime.KeepAlive(wireBuf) + if err != nil { + return nil, fmt.Errorf("decode evaluate JSON: %w", err) + } + if out == 0 { + return nil, errors.New("decode evaluate JSON: native call returned a null string") + } + decoded := []byte(copyCString(out)) + n.stringFree(out) + return decoded, nil +} + // onOSThread keeps a fallible ABI call and its immediate rw_last_error lookup // on one OS thread. The Rust error slot is thread-local and borrowed. func (n *nativeAPI) onOSThread(call func() int32) error { diff --git a/go/rustwright_test.go b/go/rustwright_test.go index 2cc4ef2..f86c47c 100644 --- a/go/rustwright_test.go +++ b/go/rustwright_test.go @@ -8,7 +8,8 @@ func TestLaunchOptionsHeadlessWireJSON(t *testing.T) { options LaunchOptions want string }{ - {name: "default", options: LaunchOptions{}, want: `{"headless":true}`}, + {name: "default omits headless so the core default applies", options: LaunchOptions{}, want: `{}`}, + {name: "explicit true", options: LaunchOptions{Headless: Bool(true)}, want: `{"headless":true}`}, {name: "explicit false", options: LaunchOptions{Headless: Bool(false)}, want: `{"headless":false}`}, } for _, test := range tests { diff --git a/python/rustwright/sync_api.py b/python/rustwright/sync_api.py index d82f375..b9eb952 100644 --- a/python/rustwright/sync_api.py +++ b/python/rustwright/sync_api.py @@ -1610,9 +1610,79 @@ class TargetClosedError(Error): _TARGET_CLOSED_MESSAGE = "Target page, context or browser has been closed" +_PAGE_CRASHED_MESSAGE = "Page crashed" +_DISCONNECTED_MESSAGE = "target or browser is closed" +_TIMEOUT_WIRE_MARKER = "__rustwright_timeout__:" +_TARGET_CLOSED_WIRE_MARKER = "__rustwright_target_closed__:" +_PAGE_CRASHED_WIRE_MARKER = "__rustwright_page_crashed__:" +_DISCONNECTED_WIRE_MARKER = "__rustwright_disconnected__:" +_WIRE_ERROR_KIND_ATTRIBUTE = "_rustwright_error_kind" +_WIRE_ERROR_PAYLOAD_ATTRIBUTE = "_rustwright_error_payload" +_TARGET_CLOSED_KINDS = frozenset({"page", "context", "browser", "target"}) + + +def _annotate_wire_error(error: Error, kind: str, payload: dict[str, Any]) -> Error: + setattr(error, _WIRE_ERROR_KIND_ATTRIBUTE, kind) + setattr(error, _WIRE_ERROR_PAYLOAD_ATTRIBUTE, dict(payload)) + return error + + +def _copy_wire_error_metadata(source: Error, target: Error) -> Error: + kind = getattr(source, _WIRE_ERROR_KIND_ATTRIBUTE, None) + payload = getattr(source, _WIRE_ERROR_PAYLOAD_ATTRIBUTE, None) + if isinstance(kind, str) and isinstance(payload, dict): + return _annotate_wire_error(target, kind, payload) + return target + + +def _decode_wire_error(message: str) -> Optional[Error]: + if message.startswith(_TIMEOUT_WIRE_MARKER): + marker = _TIMEOUT_WIRE_MARKER + kind = "timeout" + elif message.startswith(_TARGET_CLOSED_WIRE_MARKER): + marker = _TARGET_CLOSED_WIRE_MARKER + kind = "target_closed" + elif message.startswith(_PAGE_CRASHED_WIRE_MARKER): + marker = _PAGE_CRASHED_WIRE_MARKER + kind = "page_crashed" + elif message.startswith(_DISCONNECTED_WIRE_MARKER): + marker = _DISCONNECTED_WIRE_MARKER + kind = "disconnected" + else: + return None + + try: + payload = json.loads(message[len(marker) :]) + except (TypeError, ValueError, json.JSONDecodeError): + return None + if not isinstance(payload, dict): + return None + + if kind == "timeout": + if set(payload) != {"ms"} or type(payload["ms"]) is not int or payload["ms"] < 0: + return None + error: Error = TimeoutError(f"timed out after {payload['ms']} ms") + elif kind == "target_closed": + if ( + set(payload) != {"kind"} + or type(payload["kind"]) is not str + or payload["kind"] not in _TARGET_CLOSED_KINDS + ): + return None + error = TargetClosedError(_TARGET_CLOSED_MESSAGE) + elif kind == "page_crashed": + if payload: + return None + error = Error(_PAGE_CRASHED_MESSAGE) + else: + if payload: + return None + error = Error(_DISCONNECTED_MESSAGE) + return _annotate_wire_error(error, kind, payload) def _is_target_closed_message(message: str) -> bool: + # Legacy prose fallback for native and shim paths that do not emit a wire marker yet. return _TARGET_CLOSED_MESSAGE in message or any( fragment in message for fragment in ( @@ -1628,10 +1698,14 @@ def _is_target_closed_message(message: str) -> bool: def _translate_error(exc: RuntimeError) -> Error: message = str(exc) + wire_error = _decode_wire_error(message) + if wire_error is not None: + return wire_error if message.startswith("Error: InvalidSelectorError:"): message = message.removeprefix("Error: ") if _is_target_closed_message(message): return TargetClosedError(message) + # Legacy prose fallback for unconverted timeout producers. if "timed out" in message: return TimeoutError(message) return Error(message) @@ -1655,7 +1729,7 @@ def _call_with_method_prefix(method: str, fn, *args, **kwargs): error_type = TargetClosedError else: error_type = TimeoutError if isinstance(exc, TimeoutError) else Error - raise error_type(f"{method}: {message}") from None + raise _copy_wire_error_metadata(exc, error_type(f"{method}: {message}")) from None def _is_nonserializable_evaluate_result(message: str) -> bool: @@ -1671,6 +1745,12 @@ def _call_wait_with_playwright_timeout(method: str, fn, *args, **kwargs): return _call(fn, *args, **kwargs) except TimeoutError as exc: message = str(exc) + kind = getattr(exc, _WIRE_ERROR_KIND_ATTRIBUTE, None) + payload = getattr(exc, _WIRE_ERROR_PAYLOAD_ATTRIBUTE, None) + if kind == "timeout" and isinstance(payload, dict) and type(payload.get("ms")) is int: + error = TimeoutError(f"{method}: Timeout {payload['ms']}ms exceeded.") + raise _copy_wire_error_metadata(exc, error) from None + # Legacy prose fallback for timeout errors from unconverted paths. match = re.fullmatch(r"timed out after ([0-9]+(?:\.[0-9]+)?) ms", message) if match: raise TimeoutError(f"{method}: Timeout {match.group(1)}ms exceeded.") from None @@ -1680,6 +1760,15 @@ def _call_wait_with_playwright_timeout(method: str, fn, *args, **kwargs): def _is_ignorable_close_error(error: Error) -> bool: + # Structured wire kinds first: a close racing owner or transport loss is + # benign, while a genuine close timeout must still surface. + if getattr(error, _WIRE_ERROR_KIND_ATTRIBUTE, None) in ( + "disconnected", + "target_closed", + "page_crashed", + ): + return True + # Legacy cleanup-only prose emitted by unconverted CDP/session close paths. message = str(error) return any( fragment in message diff --git a/rust-native/src/lib.rs b/rust-native/src/lib.rs index 504f2d4..6a0fa6c 100644 --- a/rust-native/src/lib.rs +++ b/rust-native/src/lib.rs @@ -4,7 +4,7 @@ //! owns Chromium, CDP, and its async runtime; callers do not need Tokio. use serde::Serialize; -use serde_json::{Map, Value}; +use serde_json::Value; use std::collections::HashMap; use std::sync::mpsc::{self, RecvTimeoutError}; use std::thread; @@ -684,6 +684,12 @@ impl Page { } /// Evaluate JavaScript and decode the core's JSON wire representation. + /// + /// JavaScript bigint values become decimal strings because + /// [`serde_json::Value`] has no arbitrary-precision integer variant. + /// Negative zero retains its sign. Non-finite `f64` values are mapped + /// through their Rust constants, which become `Value::Null` because JSON + /// cannot represent NaN or infinity. pub fn evaluate( &self, expression: &str, @@ -708,8 +714,7 @@ impl Page { options.timeout, cancel, )?; - let wire: Value = serde_json::from_str(&json)?; - Ok(decode_wire_value(wire)) + decode_evaluate_wire(&json) } /// Capture a screenshot and return its encoded bytes. @@ -782,42 +787,158 @@ fn duration_from_timeout_ms(timeout_ms: Option) -> Duration { } } -fn decode_wire_value(value: Value) -> Value { +fn decode_evaluate_wire(wire_json: &str) -> Result { + let decoded = rustwright_core::decode_wire_value(wire_json)?; + let value = serde_json::from_str(&decoded)?; + Ok(map_wire_leaves(value)) +} + +fn map_wire_leaves(value: Value) -> Value { match value { - Value::Array(values) => Value::Array(values.into_iter().map(decode_wire_value).collect()), + Value::Array(values) => Value::Array(values.into_iter().map(map_wire_leaves).collect()), Value::Object(mut object) => { if object.contains_key("__rustwright_cdp_undefined__") || object.contains_key("__rustwright_cdp_symbol__") || object.contains_key("__rustwright_cdp_function__") - || object.contains_key("__rustwright_cdp_ref__") { return Value::Null; } + if let Some(value) = object.remove("__rustwright_cdp_unserializable_value__") { + return map_unserializable_value(value); + } + if let Some(value) = object.remove("__rustwright_cdp_bigint__") { + return map_bigint_value(value); + } if let Some(value) = object.remove("__rustwright_cdp_date__") { return value; } if let Some(value) = object.remove("__rustwright_cdp_url__") { return value; } - if object.contains_key("__rustwright_cdp_array__") { - return object - .remove("items") - .map(decode_wire_value) - .unwrap_or(Value::Array(Vec::new())); + if let Some(value) = object.remove("__rustwright_cdp_regexp__") { + return map_wire_leaves(value); } - if object.contains_key("__rustwright_cdp_object__") { - return object - .remove("entries") - .map(decode_wire_value) - .unwrap_or_else(|| Value::Object(Map::new())); + if let Some(value) = object.remove("__rustwright_cdp_error__") { + return map_wire_leaves(value); } Value::Object( object .into_iter() - .map(|(key, value)| (key, decode_wire_value(value))) + .map(|(key, value)| (key, map_wire_leaves(value))) .collect(), ) } value => value, } } + +fn map_unserializable_value(value: Value) -> Value { + let Value::String(value) = value else { + return value; + }; + match value.as_str() { + "NaN" => Value::from(f64::NAN), + "Infinity" => Value::from(f64::INFINITY), + "-Infinity" => Value::from(f64::NEG_INFINITY), + "-0" => Value::from(-0.0_f64), + _ => value.strip_suffix('n').map_or_else( + || Value::String(value.clone()), + |digits| Value::String(digits.to_owned()), + ), + } +} + +fn map_bigint_value(value: Value) -> Value { + match value { + Value::String(value) => { + Value::String(value.strip_suffix('n').unwrap_or(value.as_str()).to_owned()) + } + value => value, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn decode_evaluate_wire_resolves_references_instead_of_dropping_them() { + let decoded = decode_evaluate_wire( + r#"{ + "__rustwright_cdp_object__": 1, + "entries": { + "first": { + "__rustwright_cdp_array__": 2, + "items": [1, {"ok": true}] + }, + "again": {"__rustwright_cdp_ref__": 2} + } + }"#, + ) + .unwrap(); + + assert_eq!( + decoded, + json!({ + "first": [1, {"ok": true}], + "again": [1, {"ok": true}], + }) + ); + } + + #[test] + fn decode_evaluate_wire_maps_leaf_values() { + let decoded = decode_evaluate_wire( + r#"[ + {"__rustwright_cdp_unserializable_value__": "NaN"}, + {"__rustwright_cdp_unserializable_value__": "Infinity"}, + {"__rustwright_cdp_unserializable_value__": "-Infinity"}, + {"__rustwright_cdp_unserializable_value__": "-0"}, + {"__rustwright_cdp_unserializable_value__": "12345678901234567890n"}, + {"__rustwright_cdp_bigint__": "98765432109876543210"}, + {"__rustwright_cdp_date__": "2026-07-21T12:34:56.789Z"}, + {"__rustwright_cdp_regexp__": {"p": "a+b", "f": "gi"}}, + {"__rustwright_cdp_url__": "https://example.com/path"}, + {"__rustwright_cdp_error__": { + "name": "TypeError", "message": "broken", "stack": "trace" + }}, + {"__rustwright_cdp_undefined__": true}, + {"__rustwright_cdp_symbol__": true}, + {"__rustwright_cdp_function__": true} + ]"#, + ) + .unwrap(); + let values = decoded.as_array().unwrap(); + + assert_eq!(values[0], Value::Null); + assert_eq!(values[1], Value::Null); + assert_eq!(values[2], Value::Null); + let negative_zero = values[3].as_f64().unwrap(); + assert_eq!(negative_zero, 0.0); + assert!(negative_zero.is_sign_negative()); + assert_eq!(values[4], "12345678901234567890"); + assert_eq!(values[5], "98765432109876543210"); + assert_eq!(values[6], "2026-07-21T12:34:56.789Z"); + assert_eq!(values[7], json!({"p": "a+b", "f": "gi"})); + assert_eq!(values[8], "https://example.com/path"); + assert_eq!( + values[9], + json!({"name": "TypeError", "message": "broken", "stack": "trace"}) + ); + assert_eq!(&values[10..], &[Value::Null, Value::Null, Value::Null]); + } + + #[test] + fn decode_evaluate_wire_preserves_cycle_markers() { + let decoded = decode_evaluate_wire( + r#"{ + "__rustwright_cdp_object__": 1, + "entries": {"self": {"__rustwright_cdp_ref__": 1}} + }"#, + ) + .unwrap(); + + assert_eq!(decoded, json!({"self": {"__rustwright_cdp_cycle__": true}})); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1d66378..cae94f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,7 +27,7 @@ use pyo3::exceptions::{PyRuntimeError, PyValueError}; use pyo3::prelude::*; #[cfg(feature = "python")] use pyo3::types::{PyAny, PyBytes, PyModule}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tempfile::{NamedTempFile, TempDir}; use thiserror::Error; @@ -279,7 +279,14 @@ impl Drop for SpawnedTaskAbortGuard { const CDP_EVENT_LOG_LIMIT: usize = 8192; const FRAME_UTILITY_WORLD_NAME: &str = "__utility_world__"; +// Closed set of structured errors carried across the Python FFI boundary. Each +// marker prefixes exactly one JSON payload schema; user-visible prose is rebuilt +// by the Python shim and never includes these private wire markers. const ACTION_TIMEOUT_MARKER: &str = "__rustwright_action_timeout__:"; +const TIMEOUT_MARKER: &str = "__rustwright_timeout__:"; +const TARGET_CLOSED_MARKER: &str = "__rustwright_target_closed__:"; +const PAGE_CRASHED_MARKER: &str = "__rustwright_page_crashed__:"; +const DISCONNECTED_MARKER: &str = "__rustwright_disconnected__:"; const LOCATOR_TARGET_STATE_TEMPLATE: &str = r#" if (el && __SCROLL__) el.scrollIntoView({ block: 'center', inline: 'center' }); const ownerDocument = el ? (el.ownerDocument || document) : document; @@ -469,6 +476,96 @@ el.dispatchEvent(new Event('change', { bubbles: true })); return { ok: true, info }; "#; +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TargetClosedKind { + Page, + Context, + Browser, + Target, +} + +#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ActionTimeoutWirePayload { + state: String, + action: String, + last_info_json: String, + last_info_key: Option, +} + +#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct TimeoutWirePayload { + ms: u64, +} + +#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct TargetClosedWirePayload { + kind: TargetClosedKind, +} + +#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct EmptyWirePayload {} + +#[derive(Debug, Eq, PartialEq)] +enum FfiWireError { + ActionTimeout(ActionTimeoutWirePayload), + Timeout(TimeoutWirePayload), + TargetClosed(TargetClosedWirePayload), + PageCrashed, + Disconnected, +} + +impl FfiWireError { + fn marker(&self) -> &'static str { + match self { + Self::ActionTimeout(_) => ACTION_TIMEOUT_MARKER, + Self::Timeout(_) => TIMEOUT_MARKER, + Self::TargetClosed(_) => TARGET_CLOSED_MARKER, + Self::PageCrashed => PAGE_CRASHED_MARKER, + Self::Disconnected => DISCONNECTED_MARKER, + } + } + + fn wire_message(&self) -> String { + let payload = match self { + Self::ActionTimeout(payload) => serde_json::to_string(payload), + Self::Timeout(payload) => serde_json::to_string(payload), + Self::TargetClosed(payload) => serde_json::to_string(payload), + Self::PageCrashed | Self::Disconnected => serde_json::to_string(&EmptyWirePayload {}), + } + .expect("FFI wire error payloads are always JSON-serializable"); + format!("{}{payload}", self.marker()) + } + + #[cfg(test)] + fn parse(message: &str) -> Option { + if let Some(payload) = message.strip_prefix(ACTION_TIMEOUT_MARKER) { + return serde_json::from_str(payload).ok().map(Self::ActionTimeout); + } + if let Some(payload) = message.strip_prefix(TIMEOUT_MARKER) { + return serde_json::from_str(payload).ok().map(Self::Timeout); + } + if let Some(payload) = message.strip_prefix(TARGET_CLOSED_MARKER) { + return serde_json::from_str(payload).ok().map(Self::TargetClosed); + } + if let Some(payload) = message.strip_prefix(PAGE_CRASHED_MARKER) { + serde_json::from_str::(payload) + .ok() + .map(|_| Self::PageCrashed) + } else if let Some(payload) = message.strip_prefix(DISCONNECTED_MARKER) { + serde_json::from_str::(payload) + .ok() + .map(|_| Self::Disconnected) + } else { + None + } + } +} + #[derive(Debug)] pub struct ActionTimeoutError { state: &'static str, @@ -497,15 +594,13 @@ impl ActionTimeoutError { } fn wire_message(&self) -> String { - format!( - "{ACTION_TIMEOUT_MARKER}{}", - json!({ - "state": self.state, - "action": self.action, - "last_info_json": self.last_info_json, - "last_info_key": self.last_info_key, - }) - ) + FfiWireError::ActionTimeout(ActionTimeoutWirePayload { + state: self.state.to_string(), + action: self.action.to_string(), + last_info_json: self.last_info_json.clone(), + last_info_key: self.last_info_key.map(ToString::to_string), + }) + .wire_message() } } @@ -543,6 +638,12 @@ pub enum RwError { Cancelled, #[error("target or browser is closed")] Closed, + #[error("target or browser is closed")] + Disconnected, + #[error("Target page, context or browser has been closed")] + TargetClosed(TargetClosedKind), + #[error("Page crashed")] + PageCrashed, #[error("invalid input: {0}")] InvalidInput(String), #[error(transparent)] @@ -562,7 +663,14 @@ fn py_err(error: RwError) -> PyErr { match error { RwError::Message(message) => PyRuntimeError::new_err(message), RwError::InvalidInput(message) => PyValueError::new_err(message), - RwError::Timeout(ms) => PyRuntimeError::new_err(format!("timed out after {ms} ms")), + RwError::Timeout(ms) => { + PyRuntimeError::new_err(FfiWireError::Timeout(TimeoutWirePayload { ms }).wire_message()) + } + RwError::TargetClosed(kind) => PyRuntimeError::new_err( + FfiWireError::TargetClosed(TargetClosedWirePayload { kind }).wire_message(), + ), + RwError::PageCrashed => PyRuntimeError::new_err(FfiWireError::PageCrashed.wire_message()), + RwError::Disconnected => PyRuntimeError::new_err(FfiWireError::Disconnected.wire_message()), RwError::ActionTimeout(error) => PyRuntimeError::new_err(error.wire_message()), other => PyRuntimeError::new_err(other.to_string()), } @@ -1500,6 +1608,97 @@ mod tests { ); } + #[test] + fn ffi_wire_error_markers_round_trip_with_closed_payload_schemas() { + let cases = vec![ + ( + FfiWireError::ActionTimeout(ActionTimeoutWirePayload { + state: "actionable".to_string(), + action: "click".to_string(), + last_info_json: r#"{"count":0}"#.to_string(), + last_info_key: None, + }), + r#"__rustwright_action_timeout__:{"state":"actionable","action":"click","last_info_json":"{\"count\":0}","last_info_key":null}"#, + ), + ( + FfiWireError::Timeout(TimeoutWirePayload { ms: 250 }), + r#"__rustwright_timeout__:{"ms":250}"#, + ), + ( + FfiWireError::TargetClosed(TargetClosedWirePayload { + kind: TargetClosedKind::Page, + }), + r#"__rustwright_target_closed__:{"kind":"page"}"#, + ), + ( + FfiWireError::TargetClosed(TargetClosedWirePayload { + kind: TargetClosedKind::Context, + }), + r#"__rustwright_target_closed__:{"kind":"context"}"#, + ), + ( + FfiWireError::TargetClosed(TargetClosedWirePayload { + kind: TargetClosedKind::Browser, + }), + r#"__rustwright_target_closed__:{"kind":"browser"}"#, + ), + ( + FfiWireError::TargetClosed(TargetClosedWirePayload { + kind: TargetClosedKind::Target, + }), + r#"__rustwright_target_closed__:{"kind":"target"}"#, + ), + ( + FfiWireError::PageCrashed, + r#"__rustwright_page_crashed__:{}"#, + ), + ( + FfiWireError::Disconnected, + r#"__rustwright_disconnected__:{}"#, + ), + ]; + let markers = [ + ACTION_TIMEOUT_MARKER, + TIMEOUT_MARKER, + TARGET_CLOSED_MARKER, + PAGE_CRASHED_MARKER, + DISCONNECTED_MARKER, + ]; + assert_eq!( + markers.into_iter().collect::>().len(), + markers.len() + ); + + for (error, expected) in cases { + let marker = error.marker(); + let message = error.wire_message(); + assert_eq!(message, expected); + assert_eq!(FfiWireError::parse(&message), Some(error)); + assert!(message.starts_with(marker)); + assert_eq!( + markers + .iter() + .map(|item| message.matches(*item).count()) + .sum::(), + 1 + ); + } + + assert_eq!( + FfiWireError::parse(r#"__rustwright_timeout__:{"ms":1,"extra":true}"#), + None + ); + assert_eq!( + FfiWireError::parse(r#"__rustwright_target_closed__:{"kind":"tab"}"#), + None + ); + assert_eq!( + FfiWireError::parse(r#"__rustwright_page_crashed__:{"extra":true}"#), + None + ); + assert_eq!(FfiWireError::parse("plain legacy error"), None); + } + #[test] fn native_fill_result_discriminators_match_sync_errors() { assert_eq!( @@ -3376,7 +3575,7 @@ fn close_pending_cdp_commands(pending: CdpPendingMap) { .collect::>() }; for sender in senders { - let _ = sender.send(Err(RwError::Closed)); + let _ = sender.send(Err(RwError::Disconnected)); } } @@ -3679,7 +3878,7 @@ impl CdpClient { timeout: Duration, ) -> RwResult { if !self.is_connected() { - return Err(RwError::Closed); + return Err(RwError::Disconnected); } let id = self.next_id.fetch_add(1, Ordering::SeqCst); let (tx, rx) = oneshot::channel(); @@ -3703,7 +3902,7 @@ impl CdpClient { .is_err() { self.mark_closed(); - return Err(RwError::Closed); + return Err(RwError::Disconnected); } self.record_sent_command(method); @@ -3715,7 +3914,7 @@ impl CdpClient { }, other => other, }), - Ok(Err(_)) => Err(RwError::Closed), + Ok(Err(_)) => Err(RwError::Disconnected), Err(_) => Err(RwError::Timeout(timeout.as_millis() as u64)), } } @@ -3728,7 +3927,7 @@ impl CdpClient { timeout: Duration, ) -> RwResult { if !self.is_connected() { - return Err(RwError::Closed); + return Err(RwError::Disconnected); } let id = self.next_id.fetch_add(1, Ordering::SeqCst); let (tx, rx) = oneshot::channel(); @@ -3747,7 +3946,7 @@ impl CdpClient { if self.write_tx.send(CdpOutgoing::Text(payload)).is_err() { self.mark_closed(); - return Err(RwError::Closed); + return Err(RwError::Disconnected); } self.record_sent_command(method); @@ -3759,7 +3958,7 @@ impl CdpClient { }, other => other, }), - Ok(Err(_)) => Err(RwError::Closed), + Ok(Err(_)) => Err(RwError::Disconnected), Err(_) => Err(RwError::Timeout(timeout.as_millis() as u64)), } } @@ -3772,7 +3971,7 @@ impl CdpClient { timeout: Duration, ) -> RwResult> { if !self.is_connected() { - return Err(RwError::Closed); + return Err(RwError::Disconnected); } let method_json = serde_json::to_string(method)?; let session_id_json = match session_id { @@ -3796,7 +3995,7 @@ impl CdpClient { if self.write_tx.send(CdpOutgoing::Text(payload)).is_err() { self.mark_closed(); - return Err(RwError::Closed); + return Err(RwError::Disconnected); } self.record_sent_command(method); receivers.push((id, rx, pending_guard)); @@ -3812,7 +4011,7 @@ impl CdpClient { }, other => other, })?), - Ok(Err(_)) => return Err(RwError::Closed), + Ok(Err(_)) => return Err(RwError::Disconnected), Err(_) => return Err(RwError::Timeout(timeout.as_millis() as u64)), } } @@ -7305,15 +7504,13 @@ fn is_locator_wait_context_loss(error: &RwError) -> bool { fn locator_wait_terminal_error(page: &PageInner) -> Option { if page.crashed.load(Ordering::SeqCst) { - return Some(RwError::Message("Page crashed".to_string())); + return Some(RwError::PageCrashed); } if page.lifecycle.is_closing_or_closed() || page.target_closed.load(Ordering::SeqCst) || !page.browser.client.is_connected() { - return Some(RwError::Message( - "Target page, context or browser has been closed".to_string(), - )); + return Some(RwError::TargetClosed(TargetClosedKind::Page)); } None } @@ -7419,9 +7616,7 @@ async fn verify_locator_wait_target_liveness( Ok(_) => Ok(()), // Only a protocol rejection proves the target is gone; a probe timeout on a // slow or remote connection is inconclusive and must not abort the wait. - Err(RwError::Cdp { .. }) => Err(RwError::Message( - "Target page, context or browser has been closed".to_string(), - )), + Err(RwError::Cdp { .. }) => Err(RwError::TargetClosed(TargetClosedKind::Page)), Err(_) => Ok(()), } } @@ -19121,6 +19316,283 @@ async fn wait_for_event( } } +const WIRE_ARRAY_TAG: &str = "__rustwright_cdp_array__"; +const WIRE_OBJECT_TAG: &str = "__rustwright_cdp_object__"; +const WIRE_REF_TAG: &str = "__rustwright_cdp_ref__"; +const WIRE_LEAF_TAGS: [&str; 9] = [ + "__rustwright_cdp_unserializable_value__", + "__rustwright_cdp_bigint__", + "__rustwright_cdp_date__", + "__rustwright_cdp_regexp__", + "__rustwright_cdp_url__", + "__rustwright_cdp_error__", + "__rustwright_cdp_undefined__", + "__rustwright_cdp_symbol__", + "__rustwright_cdp_function__", +]; + +#[derive(Clone)] +enum WireDefinition { + Array(Vec), + Object(serde_json::Map), +} + +struct WireValueDecoder { + definitions: HashMap, + active: HashSet, +} + +impl WireValueDecoder { + fn new(wire: &Value) -> RwResult { + let mut decoder = Self { + definitions: HashMap::new(), + active: HashSet::new(), + }; + decoder.collect_definitions(wire)?; + Ok(decoder) + } + + fn collect_definitions(&mut self, value: &Value) -> RwResult<()> { + match value { + Value::Array(values) => { + for value in values { + self.collect_definitions(value)?; + } + } + Value::Object(object) => { + if object.contains_key(WIRE_REF_TAG) || is_wire_leaf(object) { + return Ok(()); + } + if let Some(id) = object.get(WIRE_ARRAY_TAG) { + let key = wire_reference_key(id)?; + let items = object + .get("items") + .and_then(Value::as_array) + .ok_or_else(|| { + RwError::InvalidInput( + "wire array wrapper must contain an items array".to_string(), + ) + })?; + self.insert_definition(key, WireDefinition::Array(items.clone()))?; + for item in items { + self.collect_definitions(item)?; + } + return Ok(()); + } + if let Some(id) = object.get(WIRE_OBJECT_TAG) { + let key = wire_reference_key(id)?; + let entries = object + .get("entries") + .and_then(Value::as_object) + .ok_or_else(|| { + RwError::InvalidInput( + "wire object wrapper must contain an entries object".to_string(), + ) + })?; + self.insert_definition(key, WireDefinition::Object(entries.clone()))?; + for entry in entries.values() { + self.collect_definitions(entry)?; + } + return Ok(()); + } + for value in object.values() { + self.collect_definitions(value)?; + } + } + _ => {} + } + Ok(()) + } + + fn insert_definition(&mut self, key: String, definition: WireDefinition) -> RwResult<()> { + if self.definitions.insert(key.clone(), definition).is_some() { + return Err(RwError::InvalidInput(format!( + "wire reference id is defined more than once: {key}" + ))); + } + Ok(()) + } + + fn decode(&mut self, value: Value) -> RwResult { + match value { + Value::Array(values) => values + .into_iter() + .map(|value| self.decode(value)) + .collect::>>() + .map(Value::Array), + Value::Object(object) => { + if let Some(id) = object.get(WIRE_REF_TAG) { + return self.resolve(&wire_reference_key(id)?); + } + if let Some(id) = object.get(WIRE_ARRAY_TAG) { + return self.resolve(&wire_reference_key(id)?); + } + if let Some(id) = object.get(WIRE_OBJECT_TAG) { + return self.resolve(&wire_reference_key(id)?); + } + if is_wire_leaf(&object) { + return Ok(Value::Object(object)); + } + object + .into_iter() + .map(|(key, value)| self.decode(value).map(|value| (key, value))) + .collect::>>() + .map(Value::Object) + } + value => Ok(value), + } + } + + fn resolve(&mut self, key: &str) -> RwResult { + if self.active.contains(key) { + return Ok(json!({"__rustwright_cdp_cycle__": true})); + } + let definition = self.definitions.get(key).cloned().ok_or_else(|| { + RwError::InvalidInput(format!("wire reference points to unknown id: {key}")) + })?; + self.active.insert(key.to_string()); + let result = match definition { + WireDefinition::Array(items) => self.decode(Value::Array(items)), + WireDefinition::Object(entries) => self.decode(Value::Object(entries)), + }; + self.active.remove(key); + result + } +} + +fn wire_reference_key(value: &Value) -> RwResult { + if !matches!(value, Value::Number(_) | Value::String(_)) { + return Err(RwError::InvalidInput( + "wire reference id must be a number or string".to_string(), + )); + } + serde_json::to_string(value).map_err(RwError::from) +} + +fn is_wire_leaf(object: &serde_json::Map) -> bool { + WIRE_LEAF_TAGS.iter().any(|tag| object.contains_key(*tag)) +} + +/// Decode the core evaluate wire format into a plain JSON tree. +/// +/// Array and object wrappers are removed and references are expanded. Repeated +/// non-cyclic references are duplicated in the output. Because JSON cannot +/// represent object identity, a reference to an active ancestor (a true cycle) +/// is replaced with `{"__rustwright_cdp_cycle__": true}`. Leaf scalar tags are +/// preserved verbatim for language bindings to map to native values. +pub fn decode_wire_value(json: &str) -> Result { + let wire = serde_json::from_str::(json)?; + let mut decoder = WireValueDecoder::new(&wire)?; + let decoded = decoder.decode(wire)?; + serde_json::to_string(&decoded).map_err(RwError::from) +} + +#[cfg(test)] +mod wire_decode_tests { + use super::*; + + #[test] + fn resolves_nested_arrays_and_objects() { + let decoded = decode_wire_value( + r#"{ + "__rustwright_cdp_object__": 1, + "entries": { + "nested": { + "__rustwright_cdp_array__": 2, + "items": [1, { + "__rustwright_cdp_object__": 3, + "entries": {"ok": true} + }] + } + } + }"#, + ) + .unwrap(); + + assert_eq!( + serde_json::from_str::(&decoded).unwrap(), + json!({"nested": [1, {"ok": true}]}) + ); + } + + #[test] + fn duplicates_repeated_references() { + let decoded = decode_wire_value( + r#"{ + "__rustwright_cdp_array__": 1, + "items": [ + { + "__rustwright_cdp_object__": 2, + "entries": {"value": [1, 2, 3]} + }, + {"__rustwright_cdp_ref__": 2} + ] + }"#, + ) + .unwrap(); + + assert_eq!( + serde_json::from_str::(&decoded).unwrap(), + json!([ + {"value": [1, 2, 3]}, + {"value": [1, 2, 3]}, + ]) + ); + } + + #[test] + fn marks_true_cycles() { + let decoded = decode_wire_value( + r#"{ + "__rustwright_cdp_object__": 1, + "entries": { + "name": "root", + "self": {"__rustwright_cdp_ref__": 1} + } + }"#, + ) + .unwrap(); + + assert_eq!( + serde_json::from_str::(&decoded).unwrap(), + json!({ + "name": "root", + "self": {"__rustwright_cdp_cycle__": true}, + }) + ); + } + + #[test] + fn preserves_every_leaf_tag_verbatim() { + let wire = json!([ + {"__rustwright_cdp_unserializable_value__": "NaN"}, + {"__rustwright_cdp_bigint__": "123"}, + {"__rustwright_cdp_date__": "2026-07-21T12:34:56.789Z"}, + {"__rustwright_cdp_regexp__": {"p": "a+b", "f": "gi"}}, + {"__rustwright_cdp_url__": "https://example.com/path?q=1"}, + {"__rustwright_cdp_error__": { + "name": "TypeError", + "message": "broken", + "stack": "TypeError: broken", + }}, + {"__rustwright_cdp_undefined__": true}, + {"__rustwright_cdp_symbol__": true}, + {"__rustwright_cdp_function__": true}, + ]); + + let decoded = decode_wire_value(&wire.to_string()).unwrap(); + + assert_eq!(serde_json::from_str::(&decoded).unwrap(), wire); + } + + #[test] + fn reports_malformed_json() { + let error = decode_wire_value(r#"{"unterminated": [1, 2}"#).unwrap_err(); + + assert!(matches!(error, RwError::Json(_))); + } +} + const RUNTIME_VALUE_SERIALIZER: &str = r#"(function __rw_serialize(value) { const marker = "__rustwright_cdp_unserializable_value__"; const seen = new WeakMap(); diff --git a/tests/test_rustwright_sync_api.py b/tests/test_rustwright_sync_api.py index fa0e046..2a05225 100644 --- a/tests/test_rustwright_sync_api.py +++ b/tests/test_rustwright_sync_api.py @@ -1964,6 +1964,109 @@ async def run() -> None: asyncio.run(run()) +@pytest.mark.parametrize( + ("wire_message", "expected_type", "expected_message", "expected_kind", "expected_payload"), + [ + ( + '__rustwright_timeout__:{"ms":250}', + TimeoutError, + "timed out after 250 ms", + "timeout", + {"ms": 250}, + ), + ( + '__rustwright_target_closed__:{"kind":"page"}', + "TargetClosedError", + "Target page, context or browser has been closed", + "target_closed", + {"kind": "page"}, + ), + ( + '__rustwright_target_closed__:{"kind":"context"}', + "TargetClosedError", + "Target page, context or browser has been closed", + "target_closed", + {"kind": "context"}, + ), + ( + '__rustwright_target_closed__:{"kind":"browser"}', + "TargetClosedError", + "Target page, context or browser has been closed", + "target_closed", + {"kind": "browser"}, + ), + ( + '__rustwright_target_closed__:{"kind":"target"}', + "TargetClosedError", + "Target page, context or browser has been closed", + "target_closed", + {"kind": "target"}, + ), + ( + "__rustwright_page_crashed__:{}", + Error, + "Page crashed", + "page_crashed", + {}, + ), + ( + "__rustwright_disconnected__:{}", + Error, + "target or browser is closed", + "disconnected", + {}, + ), + ], +) +def test_structured_native_error_markers_translate_without_wire_residue( + wire_message, expected_type, expected_message, expected_kind, expected_payload +): + import rustwright.sync_api as sync_api + + if expected_type == "TargetClosedError": + expected_type = sync_api.TargetClosedError + translated = sync_api._translate_error(RuntimeError(wire_message)) + + assert type(translated) is expected_type + assert str(translated) == expected_message + assert "__rustwright_" not in str(translated) + assert translated._rustwright_error_kind == expected_kind + assert translated._rustwright_error_payload == expected_payload + + +@pytest.mark.parametrize( + ("legacy_message", "expected_type"), + [ + ("legacy operation timed out", TimeoutError), + ("Target page, context or browser has been closed", "TargetClosedError"), + ], +) +def test_unmarked_legacy_native_errors_still_use_prose_fallback(legacy_message, expected_type): + import rustwright.sync_api as sync_api + + if expected_type == "TargetClosedError": + expected_type = sync_api.TargetClosedError + translated = sync_api._translate_error(RuntimeError(legacy_message)) + + assert type(translated) is expected_type + assert str(translated) == legacy_message + assert not hasattr(translated, "_rustwright_error_kind") + + +def test_structured_timeout_payload_survives_playwright_method_formatting(): + import rustwright.sync_api as sync_api + + def native_timeout(): + raise RuntimeError('__rustwright_timeout__:{"ms":75}') + + with pytest.raises(TimeoutError) as exc_info: + sync_api._call_wait_with_playwright_timeout("Page.goto", native_timeout) + + assert str(exc_info.value) == "Page.goto: Timeout 75ms exceeded." + assert exc_info.value._rustwright_error_kind == "timeout" + assert exc_info.value._rustwright_error_payload == {"ms": 75} + + def test_playwright_private_errors_shim_and_target_closed_type(): from benchmarks.automation_cases import playwright_private_target_closed_error_import_and_type from playwright._impl._errors import Error as ImplError