From 17f49550ac69a7476808c8cad14b9363e3b9cd0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 31 Jul 2026 01:22:26 +0200 Subject: [PATCH 1/4] fix(test): schedule one-shot mocks by call index --- .../perry-runtime/src/node_submodules/test.rs | 74 +++++++++++++++---- .../node_submodules/test_once_unit_tests.rs | 67 +++++++++++++++++ 2 files changed, 128 insertions(+), 13 deletions(-) create mode 100644 crates/perry-runtime/src/node_submodules/test_once_unit_tests.rs diff --git a/crates/perry-runtime/src/node_submodules/test.rs b/crates/perry-runtime/src/node_submodules/test.rs index 71eab4fef0..b5fee48e5b 100644 --- a/crates/perry-runtime/src/node_submodules/test.rs +++ b/crates/perry-runtime/src/node_submodules/test.rs @@ -434,7 +434,7 @@ struct MockState { id: i64, original: f64, implementation: f64, - once: Vec, + once: Vec<(usize, f64)>, calls: f64, context: f64, function: f64, @@ -640,7 +640,7 @@ fn mock_context_object(id: i64, calls: f64, include_call_tracking: bool) -> f64 set_field( obj, "mockImplementationOnce", - closure_value_with_id(mock_context_mock_implementation_once as *const u8, 1, id), + closure_value_with_id(mock_context_mock_implementation_once as *const u8, 2, id), ); } set_field( @@ -737,16 +737,45 @@ fn reset_mock_state_calls(state: &mut MockState) { update_mock_context_calls(state.context, state.calls); } +fn mock_state_call_count(state: &MockState) -> usize { + if !is_array_value(state.calls) { + return 0; + } + crate::array::js_array_length( + raw_ptr_from_value(state.calls) as *const crate::array::ArrayHeader + ) as usize +} + +fn schedule_mock_implementation_once(state: &mut MockState, call: usize, implementation: f64) { + if let Some((_, existing)) = state.once.iter_mut().find(|(index, _)| *index == call) { + *existing = implementation; + } else { + state.once.push((call, implementation)); + } + crate::gc::runtime_write_barrier_root_nanbox(implementation.to_bits()); +} + +fn take_mock_implementation(state: &mut MockState) -> f64 { + let call = mock_state_call_count(state); + if let Some(position) = state.once.iter().position(|(index, _)| *index == call) { + state.once.remove(position).1 + } else { + state.implementation + } +} + +fn prepare_mock_state_restore(state: &mut MockState) -> MockRestoreTarget { + state.implementation = state.original; + state.restore.clone() +} + fn restore_mock_state(id: i64) { let restore = MOCK_STATES.with(|states| { let mut states = states.borrow_mut(); let Some(state) = states.iter_mut().find(|state| state.id == id) else { return None; }; - state.implementation = state.original; - state.once.clear(); - reset_mock_state_calls(state); - Some(state.restore.clone()) + Some(prepare_mock_state_restore(state)) }); match restore { Some(MockRestoreTarget::ObjectProperty { @@ -829,11 +858,7 @@ extern "C" fn mock_function_invoke(closure: *const ClosureHeader, rest: f64) -> let Some(state) = states.iter_mut().find(|state| state.id == id) else { return undefined_value(); }; - if !state.once.is_empty() { - state.once.remove(0) - } else { - state.implementation - } + take_mock_implementation(state) }); let this_value = crate::object::js_implicit_this_get(); @@ -928,12 +953,31 @@ extern "C" fn mock_context_mock_implementation( extern "C" fn mock_context_mock_implementation_once( closure: *const ClosureHeader, implementation: f64, + on_call: f64, ) -> f64 { assert_callable_arg("implementation", implementation); let id = closure_id(closure); + let next_call = MOCK_STATES.with(|states| { + states + .borrow() + .iter() + .find(|state| state.id == id) + .map(mock_state_call_count) + .unwrap_or(0) + }); + let call = if is_undefined_value(on_call) { + next_call + } else { + crate::validators::validate_integer( + on_call, + "onCall", + next_call as f64, + crate::validators::MAX_SAFE_INTEGER, + ) as usize + }; MOCK_STATES.with(|states| { if let Some(state) = states.borrow_mut().iter_mut().find(|state| state.id == id) { - state.once.push(implementation); + schedule_mock_implementation_once(state, call, implementation); } }); undefined_value() @@ -1798,7 +1842,7 @@ pub(crate) fn scan_test_module_roots_mut(visitor: &mut crate::gc::RuntimeRootVis visitor.visit_nanbox_f64_slot(&mut state.calls); visitor.visit_nanbox_f64_slot(&mut state.context); visitor.visit_nanbox_f64_slot(&mut state.function); - for implementation in state.once.iter_mut() { + for (_, implementation) in state.once.iter_mut() { visitor.visit_nanbox_f64_slot(implementation); } if let MockRestoreTarget::ObjectProperty { @@ -1816,6 +1860,10 @@ pub(crate) fn scan_test_module_roots_mut(visitor: &mut crate::gc::RuntimeRootVis #[path = "test_unit_tests.rs"] mod tests; +#[cfg(test)] +#[path = "test_once_unit_tests.rs"] +mod once_tests; + fn reporter_with_kind(kind: i32, source: f64) -> f64 { if JSValue::from_bits(source.to_bits()).is_undefined() { return reporter_transform(kind); diff --git a/crates/perry-runtime/src/node_submodules/test_once_unit_tests.rs b/crates/perry-runtime/src/node_submodules/test_once_unit_tests.rs new file mode 100644 index 0000000000..1ab03784ca --- /dev/null +++ b/crates/perry-runtime/src/node_submodules/test_once_unit_tests.rs @@ -0,0 +1,67 @@ +use super::*; + +fn mock_state_with_calls(call_count: usize, implementation: f64) -> MockState { + let mut calls = crate::array::js_array_alloc(call_count as u32); + for _ in 0..call_count { + calls = crate::array::js_array_push_f64(calls, undefined_value()); + } + MockState { + id: 1, + original: implementation, + implementation, + once: Vec::new(), + calls: boxed_ptr(calls), + context: undefined_value(), + function: undefined_value(), + restore: MockRestoreTarget::None, + } +} + +fn set_call_count(state: &mut MockState, call_count: usize) { + let mut calls = crate::array::js_array_alloc(call_count as u32); + for _ in 0..call_count { + calls = crate::array::js_array_push_f64(calls, undefined_value()); + } + state.calls = boxed_ptr(calls); +} + +#[test] +fn default_once_scheduling_overwrites_the_current_call_index() { + let mut state = mock_state_with_calls(0, 10.0); + let call = mock_state_call_count(&state); + schedule_mock_implementation_once(&mut state, call, 20.0); + schedule_mock_implementation_once(&mut state, call, 30.0); + + assert_eq!(take_mock_implementation(&mut state), 30.0); + assert_eq!(take_mock_implementation(&mut state), 10.0); +} + +#[test] +fn indexed_once_scheduling_uses_absolute_call_indices() { + let mut state = mock_state_with_calls(1, 10.0); + schedule_mock_implementation_once(&mut state, 2, 20.0); + schedule_mock_implementation_once(&mut state, 4, 30.0); + + assert_eq!(take_mock_implementation(&mut state), 10.0); + set_call_count(&mut state, 2); + assert_eq!(take_mock_implementation(&mut state), 20.0); + set_call_count(&mut state, 3); + assert_eq!(take_mock_implementation(&mut state), 10.0); + set_call_count(&mut state, 4); + assert_eq!(take_mock_implementation(&mut state), 30.0); + set_call_count(&mut state, 5); + assert_eq!(take_mock_implementation(&mut state), 10.0); +} + +#[test] +fn restoring_a_mock_preserves_calls_and_scheduled_implementations() { + let mut state = mock_state_with_calls(2, 10.0); + state.implementation = 20.0; + schedule_mock_implementation_once(&mut state, 4, 30.0); + + let _ = prepare_mock_state_restore(&mut state); + + assert_eq!(state.implementation, 10.0); + assert_eq!(mock_state_call_count(&state), 2); + assert_eq!(state.once, vec![(4, 30.0)]); +} From df2ea3eb2bb6afcd7126586b0e93680b4a05a9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 31 Jul 2026 01:22:56 +0200 Subject: [PATCH 2/4] docs: add indexed mock changelog --- changelog.d/7098-node-test-mock-once-indices.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7098-node-test-mock-once-indices.md diff --git a/changelog.d/7098-node-test-mock-once-indices.md b/changelog.d/7098-node-test-mock-once-indices.md new file mode 100644 index 0000000000..4089210d31 --- /dev/null +++ b/changelog.d/7098-node-test-mock-once-indices.md @@ -0,0 +1 @@ +fix(test): schedule one-shot mock implementations by Node-compatible absolute call indices From ed19d7482bf555409f4686b91631c870467670b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 31 Jul 2026 01:28:14 +0200 Subject: [PATCH 3/4] test(test): pin recursive mock call indexing --- .../node_submodules/test_once_unit_tests.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/perry-runtime/src/node_submodules/test_once_unit_tests.rs b/crates/perry-runtime/src/node_submodules/test_once_unit_tests.rs index 1ab03784ca..0bcec44413 100644 --- a/crates/perry-runtime/src/node_submodules/test_once_unit_tests.rs +++ b/crates/perry-runtime/src/node_submodules/test_once_unit_tests.rs @@ -65,3 +65,24 @@ fn restoring_a_mock_preserves_calls_and_scheduled_implementations() { assert_eq!(mock_state_call_count(&state), 2); assert_eq!(state.once, vec![(4, 30.0)]); } + +#[test] +fn reentrant_dispatch_uses_completed_call_indices_like_node() { + let mut explicit = mock_state_with_calls(0, 10.0); + schedule_mock_implementation_once(&mut explicit, 1, 20.0); + + // Node records a call only after its implementation completes. The outer + // and nested dispatches therefore both observe completed-call index 0, so + // an explicit onCall=1 entry is not consumed by either invocation. + assert_eq!(take_mock_implementation(&mut explicit), 10.0); + assert_eq!(take_mock_implementation(&mut explicit), 10.0); + set_call_count(&mut explicit, 2); + assert_eq!(take_mock_implementation(&mut explicit), 10.0); + assert_eq!(explicit.once, vec![(1, 20.0)]); + + let mut scheduled_inside = mock_state_with_calls(0, 10.0); + assert_eq!(take_mock_implementation(&mut scheduled_inside), 10.0); + let current = mock_state_call_count(&scheduled_inside); + schedule_mock_implementation_once(&mut scheduled_inside, current, 30.0); + assert_eq!(take_mock_implementation(&mut scheduled_inside), 30.0); +} From 764b68a0db9f0844cdac76349142ca6007aa2570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 31 Jul 2026 04:04:59 +0200 Subject: [PATCH 4/4] test(node:test): exercise reentrant mock dispatch --- .../node_submodules/test_once_unit_tests.rs | 74 +++++++++++++++---- 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/crates/perry-runtime/src/node_submodules/test_once_unit_tests.rs b/crates/perry-runtime/src/node_submodules/test_once_unit_tests.rs index 0bcec44413..7500e61445 100644 --- a/crates/perry-runtime/src/node_submodules/test_once_unit_tests.rs +++ b/crates/perry-runtime/src/node_submodules/test_once_unit_tests.rs @@ -1,5 +1,28 @@ use super::*; +thread_local! { + static REENTRANT_MOCK: Cell = const { Cell::new(f64::from_bits(TAG_UNDEFINED)) }; + static REENTRANT_ACTIVE: Cell = const { Cell::new(false) }; +} + +extern "C" fn reentrant_implementation(_closure: *const ClosureHeader) -> f64 { + let recurse = REENTRANT_ACTIVE.with(|active| !active.replace(true)); + if recurse { + let mock = REENTRANT_MOCK.with(Cell::get); + js_closure_call0(raw_ptr_from_value(mock) as *const ClosureHeader); + REENTRANT_ACTIVE.with(|active| active.set(false)); + } + 10.0 +} + +extern "C" fn return_twenty(_closure: *const ClosureHeader) -> f64 { + 20.0 +} + +extern "C" fn return_thirty(_closure: *const ClosureHeader) -> f64 { + 30.0 +} + fn mock_state_with_calls(call_count: usize, implementation: f64) -> MockState { let mut calls = crate::array::js_array_alloc(call_count as u32); for _ in 0..call_count { @@ -68,21 +91,46 @@ fn restoring_a_mock_preserves_calls_and_scheduled_implementations() { #[test] fn reentrant_dispatch_uses_completed_call_indices_like_node() { - let mut explicit = mock_state_with_calls(0, 10.0); - schedule_mock_implementation_once(&mut explicit, 1, 20.0); + MOCK_STATES.with(|states| states.borrow_mut().clear()); + let outer = closure_value(reentrant_implementation as *const u8, 0); + let explicit_once = closure_value(return_twenty as *const u8, 0); + let scheduled_inside = closure_value(return_thirty as *const u8, 0); + let mock = create_mock_function(outer, outer, MockRestoreTarget::None); + let mock_ptr = raw_ptr_from_value(mock) as *const ClosureHeader; + let id = closure_id(mock_ptr); + REENTRANT_MOCK.with(|slot| slot.set(mock)); + REENTRANT_ACTIVE.with(|active| active.set(false)); // Node records a call only after its implementation completes. The outer // and nested dispatches therefore both observe completed-call index 0, so // an explicit onCall=1 entry is not consumed by either invocation. - assert_eq!(take_mock_implementation(&mut explicit), 10.0); - assert_eq!(take_mock_implementation(&mut explicit), 10.0); - set_call_count(&mut explicit, 2); - assert_eq!(take_mock_implementation(&mut explicit), 10.0); - assert_eq!(explicit.once, vec![(1, 20.0)]); - - let mut scheduled_inside = mock_state_with_calls(0, 10.0); - assert_eq!(take_mock_implementation(&mut scheduled_inside), 10.0); - let current = mock_state_call_count(&scheduled_inside); - schedule_mock_implementation_once(&mut scheduled_inside, current, 30.0); - assert_eq!(take_mock_implementation(&mut scheduled_inside), 30.0); + MOCK_STATES.with(|states| { + let mut states = states.borrow_mut(); + let state = states.iter_mut().find(|state| state.id == id).unwrap(); + schedule_mock_implementation_once(state, 1, explicit_once); + }); + assert_eq!(js_closure_call0(mock_ptr), 10.0); + + MOCK_STATES.with(|states| { + let mut states = states.borrow_mut(); + let state = states.iter_mut().find(|state| state.id == id).unwrap(); + assert_eq!(mock_state_call_count(state), 2); + assert_eq!(state.once.len(), 1); + assert_eq!(state.once[0].0, 1); + assert_eq!(state.once[0].1.to_bits(), explicit_once.to_bits()); + + let current = mock_state_call_count(state); + schedule_mock_implementation_once(state, current, scheduled_inside); + }); + assert_eq!(js_closure_call0(mock_ptr), 30.0); + + MOCK_STATES.with(|states| { + let states = states.borrow(); + let state = states.iter().find(|state| state.id == id).unwrap(); + assert_eq!(mock_state_call_count(state), 3); + assert_eq!(state.once.len(), 1); + assert_eq!(state.once[0].0, 1); + assert_eq!(state.once[0].1.to_bits(), explicit_once.to_bits()); + }); + MOCK_STATES.with(|states| states.borrow_mut().clear()); }