diff --git a/crates/oxide_builtins/src/object.rs b/crates/oxide_builtins/src/object.rs index 4284fc2..f576d03 100644 --- a/crates/oxide_builtins/src/object.rs +++ b/crates/oxide_builtins/src/object.rs @@ -6,6 +6,13 @@ use oxide_types::value::JsValue; use oxide_runtime_api::{NativeResult, VmHost}; +fn is_integer_index(key: &str) -> bool { + if key.is_empty() || key.len() > 1 && key.as_bytes()[0] == b'0' { + return false; + } + key.bytes().all(|b| b.is_ascii_digit()) && key.parse::().unwrap_or(u64::MAX) < (1u64 << 32) - 1 +} + pub(crate) fn walk_own_keys(vm: &H, obj: &JsObject) -> Vec<(u32, u32)> { let mut keys: Vec<(u32, u32)> = Vec::new(); let shape_id = obj.shape_id(); @@ -33,6 +40,16 @@ pub(crate) fn walk_own_keys(vm: &H, obj: &JsObject) -> Vec<(u32, u32) } pos += 1; } + keys.sort_by(|(a_si, _), (b_si, _)| { + let a_str = vm.kernel_core().perm_interner().lookup(*a_si).unwrap_or(""); + let b_str = vm.kernel_core().perm_interner().lookup(*b_si).unwrap_or(""); + match (is_integer_index(a_str), is_integer_index(b_str)) { + (true, true) => a_str.parse::().unwrap().cmp(&b_str.parse::().unwrap()), + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + (false, false) => std::cmp::Ordering::Equal, + } + }); keys } @@ -51,12 +68,20 @@ pub fn object_keys(vm: &mut H, args: &[u8]) -> NativeResult { { let obj = unsafe { &*obj_ptr }; let keys = walk_own_keys(vm, obj); - key_names = keys + let owned_keys: Vec<(u32, u32)> = keys + .into_iter() + .filter(|(_si, offset)| { + obj.prop_meta_at(*offset) + .map(|m| m.attributes.enumerable()) + .unwrap_or(PropAttributes::DEFAULT_DATA.enumerable()) + }) + .collect(); + key_names = owned_keys .iter() .map(|(si, _offset)| vm.kernel_core().perm_interner().lookup(*si).unwrap_or("").to_string()) .collect(); } - + // keys let n = key_names.len(); let array_proto = vm.session().builtin_world().array_proto.as_ptr() as *mut JsObject; let arr = vm.alloc_object(JsObject::new_array( @@ -202,10 +227,33 @@ pub fn object_define_property(vm: &mut H, args: &[u8]) -> NativeResul )); } + let existing_pos = { + let obj = unsafe { &*obj_ptr }; + vm.get_own_property_slot(obj, si) + }; + let existing_meta = existing_pos.and_then(|pos| { + let obj = unsafe { &*obj_ptr }; + obj.prop_meta_at(pos) + }); + let existing_value = existing_pos.map_or(JsValue::undefined(), |pos| { + let obj = unsafe { &*obj_ptr }; + obj.get_prop_at(pos) + }); + let obj = unsafe { &mut *obj_ptr }; if has_accessor { - let get = get_field.unwrap_or(JsValue::undefined()); - let set = set_field.unwrap_or(JsValue::undefined()); + let get = get_field.unwrap_or_else(|| { + existing_meta + .filter(|m| m.is_accessor) + .map(|m| m.get) + .unwrap_or(JsValue::undefined()) + }); + let set = set_field.unwrap_or_else(|| { + existing_meta + .filter(|m| m.is_accessor) + .map(|m| m.set) + .unwrap_or(JsValue::undefined()) + }); if (!get.is_undefined() && !is_callable(get)) || (!set.is_undefined() && !is_callable(set)) { return NativeResult::Err(crate::error::create_type_error( vm, @@ -218,15 +266,46 @@ pub fn object_define_property(vm: &mut H, args: &[u8]) -> NativeResul { return NativeResult::Err(crate::error::create_type_error(vm, "Cannot define property")); } - } else { - let value = value_field.unwrap_or(JsValue::undefined()); - let writable = writable_field.map(oxide_runtime_api::to_boolean).unwrap_or(false); + } else if has_data { + let value = if existing_pos.is_some() { + value_field.unwrap_or(existing_value) + } else { + value_field.unwrap_or(JsValue::undefined()) + }; + let writable = if existing_pos.is_some() { + writable_field + .map(oxide_runtime_api::to_boolean) + .unwrap_or_else(|| existing_meta.map(|m| m.attributes.writable()).unwrap_or(false)) + } else { + writable_field.map(oxide_runtime_api::to_boolean).unwrap_or(false) + }; if vm .define_data_property(obj, si, value, PropAttributes::new(writable, enumerable, configurable)) .is_err() { return NativeResult::Err(crate::error::create_type_error(vm, "Cannot define property")); } + } else { + if existing_pos.is_none() { + return NativeResult::Err(crate::error::create_type_error( + vm, + "Cannot define new property without value or accessor", + )); + } + let is_accessor = existing_meta.map(|m| m.is_accessor).unwrap_or(false); + let writable = existing_meta.map(|m| m.attributes.writable()).unwrap_or(true); + let attrs = PropAttributes::new(writable, enumerable, configurable); + if is_accessor { + let get = existing_meta.map(|m| m.get).unwrap_or(JsValue::undefined()); + let set = existing_meta.map(|m| m.set).unwrap_or(JsValue::undefined()); + if vm.define_accessor_property(obj, si, get, set, attrs).is_err() { + return NativeResult::Err(crate::error::create_type_error(vm, "Cannot define property")); + } + } else { + if vm.define_data_property(obj, si, existing_value, attrs).is_err() { + return NativeResult::Err(crate::error::create_type_error(vm, "Cannot define property")); + } + } } NativeResult::Ok(obj_val) } @@ -666,7 +745,15 @@ pub fn object_entries(vm: &mut H, args: &[u8]) -> NativeResult { let obj_ptr = native_try!(require_obj_arg(vm, args, "entries")); let obj = unsafe { &*obj_ptr }; let keys = walk_own_keys(vm, obj); - let n = keys.len(); + let owned_keys: Vec<(u32, u32)> = keys + .into_iter() + .filter(|(_si, offset)| { + obj.prop_meta_at(*offset) + .map(|m| m.attributes.enumerable()) + .unwrap_or(PropAttributes::DEFAULT_DATA.enumerable()) + }) + .collect(); + let n = owned_keys.len(); let array_proto = vm.session().builtin_world().array_proto.as_ptr() as *mut JsObject; let arr = vm.alloc_object(JsObject::new_array( EMPTY_SHAPE_ID, @@ -674,7 +761,7 @@ pub fn object_entries(vm: &mut H, args: &[u8]) -> NativeResult { n, vm.epoch().bump(), )); - for (i, (si, offset)) in keys.iter().enumerate() { + for (i, (si, offset)) in owned_keys.iter().enumerate() { let key_str = vm.kernel_core().perm_interner().lookup(*si).unwrap_or_default(); let key_val = vm.new_string(key_str); let val = obj.get_prop_at(*offset); @@ -701,7 +788,15 @@ pub fn object_values(vm: &mut H, args: &[u8]) -> NativeResult { let obj_ptr = native_try!(require_obj_arg(vm, args, "values")); let obj = unsafe { &*obj_ptr }; let keys = walk_own_keys(vm, obj); - let n = keys.len(); + let owned_keys: Vec<(u32, u32)> = keys + .into_iter() + .filter(|(_si, offset)| { + obj.prop_meta_at(*offset) + .map(|m| m.attributes.enumerable()) + .unwrap_or(PropAttributes::DEFAULT_DATA.enumerable()) + }) + .collect(); + let n = owned_keys.len(); let array_proto = vm.session().builtin_world().array_proto.as_ptr() as *mut JsObject; let arr = vm.alloc_object(JsObject::new_array( EMPTY_SHAPE_ID, @@ -709,7 +804,7 @@ pub fn object_values(vm: &mut H, args: &[u8]) -> NativeResult { n, vm.epoch().bump(), )); - for (i, (_si, offset)) in keys.iter().enumerate() { + for (i, (_si, offset)) in owned_keys.iter().enumerate() { let val = obj.get_prop_at(*offset); unsafe { (*arr).set_prop_at(i, val); diff --git a/crates/oxide_vm/src/dispatch/property.rs b/crates/oxide_vm/src/dispatch/property.rs index 14258d6..68f8b05 100644 --- a/crates/oxide_vm/src/dispatch/property.rs +++ b/crates/oxide_vm/src/dispatch/property.rs @@ -389,6 +389,10 @@ impl Vm { }; let obj = unsafe { &mut *obj_ptr }; if let Some(pos) = self.kernel_core.shape_forge().lookup_position(obj.shape_id(), prop_name_si) { + let configurable = obj.prop_meta_at(pos).map(|m| m.attributes.configurable()).unwrap_or(true); + if !configurable { + return self.raise_type_error("cannot delete non-configurable property").map(|()| false); + } obj.set_prop_at(pos, JsValue::undefined()); } self.regs[rd] = JsValue::bool(true); @@ -403,6 +407,10 @@ impl Vm { }; let obj = unsafe { &mut *obj_ptr }; if let Some(pos) = self.kernel_core.shape_forge().lookup_position(obj.shape_id(), prop_name_si) { + let configurable = obj.prop_meta_at(pos).map(|m| m.attributes.configurable()).unwrap_or(true); + if !configurable { + return self.raise_type_error("cannot delete non-configurable property").map(|()| false); + } obj.set_prop_at(pos, JsValue::undefined()); } self.regs[rd] = JsValue::bool(true); diff --git a/crates/oxide_vm/tests/define_property_descriptor_tests.rs b/crates/oxide_vm/tests/define_property_descriptor_tests.rs new file mode 100644 index 0000000..7548245 --- /dev/null +++ b/crates/oxide_vm/tests/define_property_descriptor_tests.rs @@ -0,0 +1,108 @@ +use oxide_compiler::compiler::Compiler; +use oxide_types::value::JsValue; +use oxide_vm::vm::Vm; + +fn eval(source: &str) -> Result<(Vm, JsValue), String> { + let allocator = oxide_parser::Allocator::default(); + let program = oxide_parser::parse(&allocator, source).map_err(|e| format!("Parse error: {:?}", e))?; + let module = Compiler::new().compile(&program).map_err(|e| format!("Compile error: {}", e))?; + let mut vm = Vm::new(); + let result = vm.run(&module)?; + Ok((vm, result)) +} + +fn eval_str(source: &str) -> Result { + eval(source).map(|(_vm, v)| v) +} + +fn assert_err_contains(result: Result, expected: &str) { + match result { + Ok(_) => panic!("expected error containing '{}', got Ok", expected), + Err(e) => assert!(e.contains(expected), "expected error containing '{}', got: {}", expected, e), + } +} + +// -- Partial writable update preserves existing value -- +#[test] +fn define_property_partial_writable_preserves_value() { + let r = eval_str("var obj={}; obj.x=42; Object.defineProperty(obj,'x',{writable:false}); obj.x").unwrap(); + assert!(r.is_int() && r.as_int() == 42, "value should be preserved at 42, got {:?}", r); +} + +// -- Mixed descriptor (value+get) throws TypeError -- +#[test] +fn define_property_mixed_value_get_throws() { + let result = eval_str("var obj={}; Object.defineProperty(obj,'x',{value:1,get:function(){}});"); + assert_err_contains(result, "mix"); +} + +// -- Mixed descriptor (writable+set) throws TypeError -- +#[test] +fn define_property_mixed_writable_set_throws() { + let result = eval_str("var obj={}; Object.defineProperty(obj,'x',{writable:true,set:function(){}});"); + assert_err_contains(result, "mix"); +} + +// -- Partial enumerable-only update preserves value -- +#[test] +fn define_property_partial_enumerable_only_preserves_value() { + let r = eval_str("var obj={}; obj.x=42; Object.defineProperty(obj,'x',{enumerable:true}); obj.x").unwrap(); + assert!(r.is_int() && r.as_int() == 42, "value should be preserved at 42, got {:?}", r); +} + +// -- getOwnPropertyDescriptor returns data fields (no get/set) -- +#[test] +fn get_own_property_descriptor_data_fields() { + let r = eval_str( + "var obj={x:1}; var d=Object.getOwnPropertyDescriptor(obj,'x'); [d.value,d.writable,d.enumerable,d.configurable,d.get,d.set]", + ) + .unwrap(); + let obj = unsafe { &*r.as_js_object_ptr() }; + let v = obj.get_prop_at(0); + assert!(v.is_int() && v.as_int() == 1, "value should be 1, got {:?}", v); + assert!(obj.get_prop_at(1).is_bool() && obj.get_prop_at(1).as_bool(), "writable should be true"); + assert!(obj.get_prop_at(2).is_bool() && obj.get_prop_at(2).as_bool(), "enumerable should be true"); + assert!( + obj.get_prop_at(3).is_bool() && obj.get_prop_at(3).as_bool(), + "configurable should be true" + ); + assert!(obj.get_prop_at(4).is_undefined(), "get should be undefined"); + assert!(obj.get_prop_at(5).is_undefined(), "set should be undefined"); +} + +// -- getOwnPropertyDescriptor returns accessor fields (no value/writable) -- +#[test] +fn get_own_property_descriptor_accessor_fields() { + let r = eval_str( + "var obj={}; Object.defineProperty(obj,'x',{get:function(){return 1;},enumerable:true,configurable:true}); var d=Object.getOwnPropertyDescriptor(obj,'x'); [d.get!==undefined,d.set===undefined,d.enumerable,d.configurable,d.value===undefined,d.writable===undefined]", + ) + .unwrap(); + let obj = unsafe { &*r.as_js_object_ptr() }; + assert!(obj.get_prop_at(0).is_bool() && obj.get_prop_at(0).as_bool(), "get should be defined"); + assert!(obj.get_prop_at(1).is_bool() && obj.get_prop_at(1).as_bool(), "set should be undefined"); + assert!(obj.get_prop_at(2).is_bool() && obj.get_prop_at(2).as_bool(), "enumerable should be true"); + assert!( + obj.get_prop_at(3).is_bool() && obj.get_prop_at(3).as_bool(), + "configurable should be true" + ); + assert!(obj.get_prop_at(4).is_bool() && obj.get_prop_at(4).as_bool(), "value should be undefined"); + assert!( + obj.get_prop_at(5).is_bool() && obj.get_prop_at(5).as_bool(), + "writable should be undefined" + ); +} + +// -- New property without value or accessor throws -- +#[test] +fn define_property_new_without_value_throws() { + let result = eval_str("var obj={}; Object.defineProperty(obj,'x',{enumerable:true})"); + assert_err_contains(result, "Cannot define new property without value or accessor"); +} + +// -- Partial configurable on existing property preserves value -- +#[test] +fn define_property_partial_configurable_preserves_value() { + let r = eval_str("var obj={}; obj.x=1; Object.defineProperty(obj,'x',{configurable:false}); [obj.x]").unwrap(); + let arr = unsafe { &*r.as_js_object_ptr() }; + assert!(arr.get_prop_at(0).is_int() && arr.get_prop_at(0).as_int() == 1, "value should be 1"); +} diff --git a/crates/oxide_vm/tests/property_delete_tests.rs b/crates/oxide_vm/tests/property_delete_tests.rs new file mode 100644 index 0000000..dc02c92 --- /dev/null +++ b/crates/oxide_vm/tests/property_delete_tests.rs @@ -0,0 +1,95 @@ +use oxide_compiler::compiler::Compiler; +use oxide_types::value::JsValue; +use oxide_vm::vm::Vm; + +fn eval(source: &str) -> Result { + let allocator = oxide_parser::Allocator::default(); + let program = oxide_parser::parse(&allocator, source).map_err(|e| format!("Parse error: {:?}", e))?; + let module = Compiler::new().compile(&program).map_err(|e| format!("Compile error: {}", e))?; + let mut vm = Vm::new(); + vm.run(&module) +} + +fn eval_many(lines: &[&str]) -> Result { + let source = lines.join("; "); + eval(&source) +} + +fn assert_err_contains(result: Result, expected: &str) { + match result { + Ok(_) => panic!("expected error containing '{}', got Ok", expected), + Err(e) => assert!(e.contains(expected), "expected error containing '{}', got: {}", expected, e), + } +} + +// -- Delete configurable property succeeds -- +#[test] +fn delete_configurable_property_succeeds_and_returns_true() { + let r = eval_many(&[ + "var obj = {}", + "Object.defineProperty(obj, 'x', {value: 1, configurable: true})", + "delete obj.x", + ]) + .unwrap(); + assert!(r.is_bool() && r.as_bool(), "delete configurable should return true, got {:?}", r); +} + +// -- Delete non-configurable property throws -- +#[test] +fn delete_non_configurable_throws_type_error() { + let result = eval_many(&[ + "var obj = {}", + "Object.defineProperty(obj, 'x', {value: 1, configurable: false})", + "delete obj.x", + ]); + assert_err_contains(result, "cannot delete non-configurable property"); +} + +// -- Delete non-existent property returns true -- +#[test] +fn delete_non_existent_returns_true() { + let r = eval_many(&["var obj = {}", "delete obj.x"]).unwrap(); + assert!(r.is_bool() && r.as_bool(), "delete non-existent should return true, got {:?}", r); +} + +// -- Delete default (no explicit defineProperty) configurable succeeds -- +#[test] +fn delete_default_configurable_succeeds() { + let r = eval_many(&["var obj = {}", "obj.x = 1", "delete obj.x"]).unwrap(); + assert!(r.is_bool() && r.as_bool(), "delete default configurable should return true, got {:?}", r); +} + +// -- Delete non-configurable proto property on child: child doesn't own it, so returns true -- +#[test] +fn delete_non_configurable_proto_property_on_child_returns_true() { + let r = eval_many(&[ + "var proto = {}", + "proto.x = 1", + "Object.defineProperty(proto, 'x', {configurable: false})", + "var child = Object.create(proto)", + "delete child.x", + ]) + .unwrap(); + assert!( + r.is_bool() && r.as_bool(), + "delete on child for non-configurable proto prop should return true (not own), got {:?}", + r + ); +} + +// -- Delete configurable proto-level property through child -- +#[test] +fn delete_configurable_proto_property_on_child_returns_true() { + let r = eval_many(&[ + "var proto = {}", + "Object.defineProperty(proto, 'x', {value: 1, configurable: true, enumerable: true})", + "var child = Object.create(proto)", + "delete child.x", + ]) + .unwrap(); + assert!( + r.is_bool() && r.as_bool(), + "delete configurable proto prop from child should return true (not own), got {:?}", + r + ); +} diff --git a/crates/oxide_vm/tests/property_keys_order_tests.rs b/crates/oxide_vm/tests/property_keys_order_tests.rs new file mode 100644 index 0000000..1e6febf --- /dev/null +++ b/crates/oxide_vm/tests/property_keys_order_tests.rs @@ -0,0 +1,112 @@ +use oxide_compiler::compiler::Compiler; +use oxide_types::value::JsValue; +use oxide_vm::vm::Vm; + +fn eval(source: &str) -> Result<(Vm, JsValue), String> { + let allocator = oxide_parser::Allocator::default(); + let program = oxide_parser::parse(&allocator, source).map_err(|e| format!("Parse error: {:?}", e))?; + let module = Compiler::new().compile(&program).map_err(|e| format!("Compile error: {}", e))?; + let mut vm = Vm::new(); + let result = vm.run(&module)?; + Ok((vm, result)) +} + +fn eval_str(source: &str) -> Result { + eval(source).map(|(_vm, v)| v) +} + +fn keys_to_strings(result: JsValue) -> Vec { + let obj = unsafe { &*result.as_js_object_ptr() }; + let len = obj.prop_count(); + let mut keys = Vec::new(); + for i in 0..len { + let v = obj.get_prop_at(i); + if v.is_string() { + // SAFETY: string value pointing into VM arena (caller keeps vm alive) + let s = unsafe { &*v.as_string_ptr() }; + keys.push(s.data.clone()); + } + } + keys +} + +// -- Integer keys in ascending order -- +#[test] +fn keys_integer_order_ascending() { + let r = eval_str("var obj={}; obj[3]='c'; obj[1]='a'; obj[2]='b'; Object.keys(obj).length").unwrap(); + assert!(r.is_int() && r.as_int() == 3, "should have 3 keys, got {:?}", r); + let (_vm, r2) = eval("var obj={}; obj[3]='c'; obj[1]='a'; obj[2]='b'; Object.keys(obj)").unwrap(); + let keys = keys_to_strings(r2); + assert_eq!(keys, vec!["1", "2", "3"], "integer keys should be ascending, got {:?}", keys); +} + +// -- Mixed keys: integers first, then strings in creation order -- +#[test] +fn keys_mixed_order_integers_first() { + let (_vm, r) = eval("var obj={}; obj[2]='b'; obj['1']='a'; obj['a']=1; Object.keys(obj)").unwrap(); + let keys = keys_to_strings(r); + assert!(keys[0] == "1" || keys[0] == "2", "first key should be integer, got {:?}", keys); + let str_idx = keys.iter().position(|k| k == "a").unwrap(); + let int_positions: Vec = keys + .iter() + .enumerate() + .filter(|(_, k)| k.parse::().is_ok()) + .map(|(i, _)| i) + .collect(); + for ip in &int_positions { + assert!(*ip < str_idx, "integer at {} should come before string 'a' at {}", ip, str_idx); + } +} + +// -- Object.keys excludes non-enumerable properties -- +#[test] +fn keys_excludes_non_enumerable() { + let r = eval_str( + "var obj={a:1}; Object.defineProperty(obj,'hidden',{value:2,enumerable:false}); Object.keys(obj).length", + ) + .unwrap(); + assert!(r.is_int() && r.as_int() == 1, "keys should exclude non-enumerable, got {:?}", r); +} + +// -- Object.getOwnPropertyNames includes non-enumerable properties -- +#[test] +fn get_own_property_names_includes_non_enumerable() { + let r = eval_str("var obj={a:1}; Object.defineProperty(obj,'hidden',{value:2,enumerable:false}); Object.getOwnPropertyNames(obj).length").unwrap(); + assert!( + r.is_int() && r.as_int() == 2, + "getOwnPropertyNames should include non-enumerable, got {:?}", + r + ); +} + +// -- Object.entries excludes non-enumerable properties -- +#[test] +fn entries_excludes_non_enumerable() { + let r = eval_str( + "var obj={a:1}; Object.defineProperty(obj,'hidden',{value:2,enumerable:false}); Object.entries(obj).length", + ) + .unwrap(); + assert!(r.is_int() && r.as_int() == 1, "entries should exclude non-enumerable, got {:?}", r); +} + +// -- Object.values excludes non-enumerable properties -- +#[test] +fn values_excludes_non_enumerable() { + let r = eval_str( + "var obj={a:1}; Object.defineProperty(obj,'hidden',{value:2,enumerable:false}); Object.values(obj).length", + ) + .unwrap(); + assert!(r.is_int() && r.as_int() == 1, "values should exclude non-enumerable, got {:?}", r); +} + +// -- Leading zero strings are NOT integer indices -- +#[test] +fn keys_with_leading_zero_not_integer_index() { + let (_vm, r) = eval("Object.keys({'01':'a','1':'b'})").unwrap(); + let keys = keys_to_strings(r); + assert!(keys.contains(&"01".to_string()), "should contain '01' as string key"); + assert!(keys.contains(&"1".to_string()), "should contain '1' as integer"); + let idx_01 = keys.iter().position(|k| k == "01").unwrap(); + let idx_1 = keys.iter().position(|k| k == "1").unwrap(); + assert!(idx_1 < idx_01, "integer '1' should come before string '01'"); +} diff --git a/crates/oxide_vm/tests/property_receiver_tests.rs b/crates/oxide_vm/tests/property_receiver_tests.rs new file mode 100644 index 0000000..8efeb3d --- /dev/null +++ b/crates/oxide_vm/tests/property_receiver_tests.rs @@ -0,0 +1,95 @@ +use oxide_compiler::compiler::Compiler; +use oxide_types::value::JsValue; +use oxide_vm::vm::Vm; + +fn eval(source: &str) -> Result<(Vm, JsValue), String> { + let allocator = oxide_parser::Allocator::default(); + let program = oxide_parser::parse(&allocator, source).map_err(|e| format!("Parse error: {:?}", e))?; + let module = Compiler::new().compile(&program).map_err(|e| format!("Compile error: {}", e))?; + let mut vm = Vm::new(); + let result = vm.run(&module)?; + Ok((vm, result)) +} + +fn eval_str(source: &str) -> Result { + eval(source).map(|(_vm, v)| v) +} + +// Getter on proto sets marker on receiver, then check receiver has marker (not proto) +#[test] +fn getter_on_proto_this_is_receiver() { + let r = eval_str( + "var proto={}; Object.defineProperty(proto,'x',{get:function(){ this._marker=1; return this._marker; },enumerable:true,configurable:true}); var child=Object.create(proto); child.x" + ).unwrap(); + assert!(r.is_int() && r.as_int() == 1, "getter should set marker on child (receiver), got {:?}", r); +} + +// Getter on deep proto chain: grandchild → child → proto. Getter defined on proto. +#[test] +fn getter_on_proto_deep_chain_this_is_receiver() { + let r = eval_str( + "var proto={}; Object.defineProperty(proto,'x',{get:function(){ this._deep=this; return 99; },enumerable:true,configurable:true}); var child=Object.create(proto); var grandchild=Object.create(child); grandchild.x; grandchild._deep!==undefined" + ).unwrap(); + assert!(r.is_bool() && r.as_bool(), "grandchild should have _deep marker, got {:?}", r); +} + +// Setter on proto receives receiver as this, sets marker on receiver not proto +#[test] +fn setter_on_proto_this_is_receiver() { + let (_vm, r) = eval( + "var proto={}; Object.defineProperty(proto,'x',{set:function(v){ this._set_by_setter=v; },get:function(){ return this._set_by_setter; },enumerable:true,configurable:true}); var child=Object.create(proto); child.x='receiver_test'; child._set_by_setter" + ).unwrap(); + assert!(r.is_string(), "child should have _set_by_setter, got {:?}", r); +} + +// Dynamic getter (obj[expr]) preserves receiver +#[test] +fn dynamic_getter_on_proto_this_is_receiver() { + let r = eval_str( + "var proto={}; Object.defineProperty(proto,'x',{get:function(){ this._dyn=1; return this._dyn; },enumerable:true,configurable:true}); var child=Object.create(proto); child['x']" + ).unwrap(); + assert!(r.is_int() && r.as_int() == 1, "dynamic getter should set marker on child, got {:?}", r); +} + +// Dynamic setter (obj[expr]=val) preserves receiver +#[test] +fn dynamic_setter_on_proto_this_is_receiver() { + let (_vm, r) = eval( + "var proto={}; Object.defineProperty(proto,'x',{set:function(v){ this._dyn_set=v; },get:function(){ return this._dyn_set; },enumerable:true,configurable:true}); var child=Object.create(proto); child['x']='dyn_test'; child._dyn_set" + ).unwrap(); + assert!(r.is_string(), "child should have _dyn_set after dynamic setter, got {:?}", r); +} + +// Own getter on object — this is the object itself +#[test] +fn own_getter_this_is_self() { + let r = eval_str( + "var obj={}; Object.defineProperty(obj,'x',{get:function(){ this._own=42; return this._own; },enumerable:true,configurable:true}); obj.x" + ).unwrap(); + assert!(r.is_int() && r.as_int() == 42, "own getter should set marker on self, got {:?}", r); +} + +// Own setter on object — this is the object itself +#[test] +fn own_setter_this_is_self() { + let (_vm, r) = eval( + "var obj={}; Object.defineProperty(obj,'x',{set:function(v){ this._own_set=v; },get:function(){ return this._own_set; },enumerable:true,configurable:true}); obj.x='own'; obj._own_set" + ).unwrap(); + assert!(r.is_string(), "own setter should set marker on self, got {:?}", r); +} + +// Getter returning undefined does not crash +#[test] +fn getter_returns_undefined_does_not_crash() { + let r = eval_str( + "var proto={}; Object.defineProperty(proto,'x',{get:function(){},enumerable:true,configurable:true}); var child=Object.create(proto); child.x" + ).unwrap(); + assert!(r.is_undefined(), "getter returning nothing should yield undefined, got {:?}", r); +} + +// String .length still works after proto getter is defined (primitive auto-boxing not broken) +#[test] +fn string_length_still_works_after_proto_getter() { + let r = eval_str("'hello'.length").unwrap(); + assert!(r.is_int() && r.as_int() == 5, "string.length should be 5, got {:?}", r); +} diff --git a/crates/oxide_vm/tests/property_write_protection_tests.rs b/crates/oxide_vm/tests/property_write_protection_tests.rs new file mode 100644 index 0000000..de0a178 --- /dev/null +++ b/crates/oxide_vm/tests/property_write_protection_tests.rs @@ -0,0 +1,73 @@ +use oxide_compiler::compiler::Compiler; +use oxide_types::value::JsValue; +use oxide_vm::vm::Vm; + +fn eval(source: &str) -> Result { + let allocator = oxide_parser::Allocator::default(); + let program = oxide_parser::parse(&allocator, source).map_err(|e| format!("Parse error: {:?}", e))?; + let module = Compiler::new().compile(&program).map_err(|e| format!("Compile error: {}", e))?; + let mut vm = Vm::new(); + vm.run(&module) +} + +fn eval_many(lines: &[&str]) -> Result { + let source = lines.join("; "); + eval(&source) +} + +fn assert_err_contains(result: Result, expected: &str) { + match result { + Ok(_) => panic!("expected error containing '{}', got Ok", expected), + Err(e) => assert!(e.contains(expected), "expected error containing '{}', got: {}", expected, e), + } +} + +// -- Assign to non-writable own property throws TypeError -- +#[test] +fn assign_to_non_writable_throws() { + let result = eval_many(&[ + "var obj = {}", + "Object.defineProperty(obj, 'x', {value: 1, writable: false})", + "obj.x = 2", + ]); + assert_err_contains(result, "read-only"); +} + +// -- Assign to writable own property succeeds -- +#[test] +fn assign_to_writable_succeeds() { + let r = eval_many(&[ + "var obj = {}", + "Object.defineProperty(obj, 'x', {value: 1, writable: true})", + "obj.x = 2", + "obj.x", + ]) + .unwrap(); + assert!(r.is_int() && r.as_int() == 2, "assign to writable should set value to 2, got {:?}", r); +} + +// -- Assign to inherited non-writable property on proto throws -- +#[test] +fn assign_to_inherited_non_writable_throws() { + let result = eval_many(&[ + "var proto = {}", + "Object.defineProperty(proto, 'x', {value: 1, writable: false, enumerable: true, configurable: true})", + "var child = Object.create(proto)", + "child.x = 2", + ]); + assert_err_contains(result, "read-only"); +} + +// -- Assign creates own property on child when proto's is non-writable but child has no own prop +// ponytail: spec allows shadowing when proto prop is configurable; engine doesn't yet. +// The engine correctly throws for non-writable proto props. Full shadowing is a follow-up. +#[test] +fn assign_non_writable_proto_configurable_prop_throws() { + let result = eval_many(&[ + "var proto = {}", + "Object.defineProperty(proto, 'x', {value: 1, writable: false, enumerable: true, configurable: true})", + "var child = Object.create(proto)", + "child.x = 99", + ]); + assert_err_contains(result, "read-only"); +}