Skip to main content

runmat_runtime/testing/
context.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3
4use runmat_test::context::{TestCommand, TestExecutionContext};
5use runmat_test::descriptor::{ProcedureDescriptor, ProcedureKind, SourceDescriptor, SourceSpan};
6use runmat_test::lifecycle::{ExecutionPhase, FixtureScopeKey};
7use runmat_test::protocol::ProtocolLimits;
8
9#[derive(Clone, Debug)]
10pub struct ActiveTestContext {
11    pub execution: TestExecutionContext,
12    pub phase: ExecutionPhase,
13    pub scope: FixtureScopeKey,
14}
15
16#[derive(Clone, Debug)]
17pub struct RuntimeTeardownInvocation {
18    pub semantic_path: String,
19    pub callback: runmat_value::Value,
20    pub arguments: Vec<runmat_value::Value>,
21}
22
23#[derive(Debug)]
24pub(crate) struct ContextState {
25    active: ActiveTestContext,
26    limits: ProtocolLimits,
27    commands: Vec<TestCommand>,
28    runtime_teardowns: Vec<RuntimeTeardownInvocation>,
29}
30
31#[derive(Clone, Debug)]
32pub struct TestContextHandle {
33    state: Rc<RefCell<ContextState>>,
34}
35
36impl TestContextHandle {
37    pub fn active(&self) -> ActiveTestContext {
38        self.state.borrow().active.clone()
39    }
40
41    pub fn commands(&self) -> Vec<TestCommand> {
42        self.state.borrow().commands.clone()
43    }
44
45    pub fn take_commands(&self) -> Vec<TestCommand> {
46        std::mem::take(&mut self.state.borrow_mut().commands)
47    }
48
49    pub fn runtime_teardowns(&self) -> Vec<RuntimeTeardownInvocation> {
50        self.state.borrow().runtime_teardowns.clone()
51    }
52
53    pub fn take_runtime_teardowns(&self) -> Vec<RuntimeTeardownInvocation> {
54        std::mem::take(&mut self.state.borrow_mut().runtime_teardowns)
55    }
56}
57
58#[derive(Debug)]
59pub struct TestContextGuard {
60    state: Rc<RefCell<ContextState>>,
61    context: Option<Rc<crate::context::RuntimeContextState>>,
62}
63
64impl TestContextGuard {
65    pub fn handle(&self) -> TestContextHandle {
66        TestContextHandle {
67            state: Rc::clone(&self.state),
68        }
69    }
70}
71
72impl Drop for TestContextGuard {
73    fn drop(&mut self) {
74        if let Some(context) = &self.context {
75            let popped = context.test_contexts.borrow_mut().pop();
76            debug_assert!(popped
77                .as_ref()
78                .is_some_and(|state| Rc::ptr_eq(state, &self.state)));
79        } else {
80            TEST_CONTEXT_STACK.with(|stack| {
81                let popped = stack.borrow_mut().pop();
82                debug_assert!(popped
83                    .as_ref()
84                    .is_some_and(|state| Rc::ptr_eq(state, &self.state)));
85            });
86        }
87    }
88}
89
90thread_local! {
91    static TEST_CONTEXT_STACK: RefCell<Vec<Rc<RefCell<ContextState>>>> =
92        const { RefCell::new(Vec::new()) };
93}
94
95pub fn install_test_context(active: ActiveTestContext, limits: ProtocolLimits) -> TestContextGuard {
96    let context = crate::context::legacy::active().map(|context| Rc::clone(context.state()));
97    install_test_context_for_state(context, active, limits)
98}
99
100/// Install lifecycle state into a specific session-owned runtime context.
101///
102/// Hosts that are about to enter a known context must use this form so the
103/// lifecycle state follows the invocation instead of relying on the legacy
104/// ambient stack.
105pub fn install_test_context_in(
106    context: &crate::context::RuntimeContext,
107    active: ActiveTestContext,
108    limits: ProtocolLimits,
109) -> TestContextGuard {
110    install_test_context_for_state(Some(Rc::clone(context.state())), active, limits)
111}
112
113fn install_test_context_for_state(
114    context: Option<Rc<crate::context::RuntimeContextState>>,
115    active: ActiveTestContext,
116    limits: ProtocolLimits,
117) -> TestContextGuard {
118    let state = Rc::new(RefCell::new(ContextState {
119        active,
120        limits,
121        commands: Vec::new(),
122        runtime_teardowns: Vec::new(),
123    }));
124    if let Some(context) = &context {
125        context.test_contexts.borrow_mut().push(Rc::clone(&state));
126    } else {
127        TEST_CONTEXT_STACK.with(|stack| stack.borrow_mut().push(Rc::clone(&state)));
128    }
129    TestContextGuard { state, context }
130}
131
132fn current_test_context_state() -> Option<Rc<RefCell<ContextState>>> {
133    if let Some(context) = crate::context::legacy::active() {
134        return context.state().test_contexts.borrow().last().cloned();
135    }
136    TEST_CONTEXT_STACK.with(|stack| stack.borrow().last().cloned())
137}
138
139pub fn record_runtime_teardown(
140    callback: runmat_value::Value,
141    arguments: Vec<runmat_value::Value>,
142) -> Result<(), &'static str> {
143    {
144        let state = current_test_context_state()
145            .ok_or("addTeardown requires an active test lifecycle context")?;
146        let mut state = state.borrow_mut();
147        if state.active.phase.is_teardown() {
148            return Err("addTeardown cannot be registered from a teardown phase");
149        }
150        if state.commands.len() >= state.limits.max_commands_per_invocation as usize {
151            return Err("testing command limit exceeded");
152        }
153        let callback_name = match &callback {
154            runmat_value::Value::FunctionHandle(name)
155            | runmat_value::Value::ExternalFunctionHandle(name)
156            | runmat_value::Value::MethodFunctionHandle(name) => name.clone(),
157            runmat_value::Value::BoundFunctionHandle { name, .. } => name.clone(),
158            runmat_value::Value::Closure(closure) => closure.function_name.clone(),
159            _ => return Err("addTeardown callback must be a function handle"),
160        };
161        let semantic_path = format!(
162            "runtime-teardown:{}:{}:{}",
163            state.active.execution.test_id.as_str(),
164            state.active.execution.attempt,
165            state.runtime_teardowns.len()
166        );
167        let source = crate::source_context::current_source_info();
168        let relative_path = source
169            .as_ref()
170            .map(|source| {
171                source
172                    .fullpath_name
173                    .as_deref()
174                    .unwrap_or(source.name.as_ref())
175                    .to_string()
176            })
177            .unwrap_or_default();
178        let procedure = ProcedureDescriptor {
179            semantic_path: semantic_path.clone(),
180            display_name: callback_name,
181            kind: ProcedureKind::Teardown,
182            source: SourceDescriptor {
183                owner_identity: "runtime:test-context".into(),
184                semantic_path: semantic_path.clone(),
185                relative_path,
186                span: SourceSpan {
187                    start_byte: 0,
188                    end_byte: 0,
189                    start_line: 1,
190                    start_column: 1,
191                    end_line: 1,
192                    end_column: 1,
193                },
194            },
195        };
196        let scope = state.active.scope.clone();
197        state
198            .commands
199            .push(TestCommand::AddTeardown { scope, procedure });
200        state.runtime_teardowns.push(RuntimeTeardownInvocation {
201            semantic_path,
202            callback,
203            arguments,
204        });
205        Ok(())
206    }
207}
208
209pub fn active_test_context() -> Option<ActiveTestContext> {
210    current_test_context_state().map(|state| state.borrow().active.clone())
211}
212
213pub fn record_test_command(command: TestCommand) -> Result<(), &'static str> {
214    {
215        let state = current_test_context_state()
216            .ok_or("testing command requires an active test lifecycle context")?;
217        let mut state = state.borrow_mut();
218        if state.commands.len() >= state.limits.max_commands_per_invocation as usize {
219            return Err("testing command limit exceeded");
220        }
221        state.commands.push(command);
222        Ok(())
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use runmat_test::descriptor::FixtureScope;
229    use runmat_test::identity::{RunId, TestId};
230    use runmat_test::lifecycle::QualificationKind;
231    use runmat_test::result::{Diagnostic, DiagnosticSeverity};
232
233    use super::*;
234
235    #[test]
236    fn scoped_context_records_bounded_domain_commands() {
237        let guard = install_test_context(
238            ActiveTestContext {
239                execution: TestExecutionContext {
240                    run_id: RunId::derive("revision", "run"),
241                    test_id: TestId::derive(&runmat_test::identity::TestIdentityInput {
242                        owner_identity: "owner",
243                        relative_source_identity: "test.m",
244                        semantic_scheme: "function",
245                        semantic_item_path: "test",
246                        parameter_identity: "",
247                        fixture_identity: "",
248                    }),
249                    attempt: 1,
250                    random_seed: 7,
251                },
252                phase: ExecutionPhase::TestBody,
253                scope: FixtureScopeKey {
254                    scope: FixtureScope::Test,
255                    identity: "test".into(),
256                },
257            },
258            ProtocolLimits::default(),
259        );
260        record_test_command(TestCommand::Qualify {
261            qualification: QualificationKind::VerificationFailed,
262            diagnostic: Diagnostic {
263                identifier: "RunMat:VerificationFailed".into(),
264                message: "nope".into(),
265                severity: DiagnosticSeverity::Error,
266                phase: ExecutionPhase::TestBody,
267                source: None,
268                details: Vec::new(),
269            },
270        })
271        .unwrap();
272        assert_eq!(guard.handle().commands().len(), 1);
273        drop(guard);
274        assert!(active_test_context().is_none());
275    }
276}