[Dexter] Add !then node (#202546)

In order to exercise more control over stepping in Dexter tests, this
patch adds the `!then` node which can be used to step out of a function
or exit the current test. Unlike expect nodes, !then nodes appear as
direct singular children of a state node:

!where {lines: 10}: !then finish

The two currently available commands are "step_out" and "finish".
step_out performs a debugger "step out" command, skipping over all
!wheres in the current frame and not stepping into any lower !wheres.
The finish command ends the debugger session immediately after finishing
the current step.
This commit is contained in:
Stephen Tozer 2026-06-17 15:43:24 +01:00 committed by GitHub
parent 97df9d1c25
commit d08ca31f48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 196 additions and 10 deletions

View file

@ -130,11 +130,24 @@ class ScriptDebuggerController(DebuggerControllerBase):
]
self.debugger.collect_watches(step_info, watches)
active_thens = [
then
for where_match in active_where_matches.values()
for then in where_match.active_thens
]
should_step_out = any(then.command == "step_out" for then in active_thens)
should_finish = any(then.command == "finish" for then in active_thens)
# Our stepping behaviour is as follows:
# - If any !where matches the current stack frame, we step.
# - If any !then is active, we follow its command.
# - Otherwise, if any !where matches the current stack frame, we step.
# - Otherwise, if any !where matches any non-current stack frame, we step out.
# - Otherwise, we continue.
if any(
if should_finish:
next_action = DebuggerAction.EXIT
elif should_step_out:
next_action = DebuggerAction.STEP_OUT
elif any(
where_match.frame_idx == 0
for where_match in active_where_matches.values()
):
@ -155,14 +168,15 @@ class ScriptDebuggerController(DebuggerControllerBase):
if (
bp_ids
and where not in script.root_wheres
and where not in pending_wheres
and (where not in pending_wheres or should_step_out)
):
bp_to_delete.extend(bp_ids)
bp_ids.clear()
self.debugger.delete_breakpoints(bp_to_delete)
for where in pending_wheres:
if not self._where_bps[where]:
self.add_where_entry_bp(where)
if not should_step_out:
for where in pending_wheres:
if not self._where_bps[where]:
self.add_where_entry_bp(where)
if step_info.current_frame:
self._step_index += 1

View file

@ -13,7 +13,7 @@ from typing import Dict, List, Tuple
from dex.dextIR import FrameIR, StepIR
from dex.test_script import DexterScript, Scope
from dex.test_script.Nodes import Expect, FileLabels, Value, Where
from dex.test_script.Nodes import Expect, FileLabels, Value, Where, Then
def is_subpath(subpath: str, superpath: str) -> bool:
@ -61,6 +61,7 @@ class WhereMatchResult:
frame_idx: int
active_expects: List[Value] = field(default_factory=list)
active_thens: List[Then] = field(default_factory=list)
pending_wheres: List[Where] = field(default_factory=list)
@ -118,6 +119,17 @@ def get_active_where_matches(
):
active_where_expects[scope.where].active_expects.append(expect)
script.visit_script(visit_where=get_active_wheres, visit_expect=get_active_expects)
def get_active_thens(then: Then, scope: Scope):
if (
scope.where in active_where_expects
and active_where_expects[scope.where].frame_idx == 0
):
active_where_expects[scope.where].active_thens.append(then)
script.visit_script(
visit_where=get_active_wheres,
visit_expect=get_active_expects,
visit_then=get_active_thens,
)
return active_where_expects

View file

@ -23,6 +23,7 @@ def setup_yaml_parser(loader):
Value,
DexRange,
Label,
Then,
]
for c in reg_classes:
c.register_yaml(loader)
@ -192,6 +193,45 @@ class Value(Expect):
yaml.add_representer(Value, Value.representer)
##############
## Execution Nodes: Can appear as leaf nodes directly under a state node to perform debugger actions when they become
## active, to advance the debugger state.
class Then:
"""Used to perform actions, such as finishing the test or continuing. Will trigger as soon as it becomes active, and
intends to advance debugger state, so this must be used directly under a state node, e.g.:
`!where {line: 4}: !then finish`
"""
def __init__(self, command: str):
self.command = command
if not self.is_valid():
raise DexterNodeError(self, f'Invalid !then command "{self.command}"')
def is_valid(self) -> bool:
valid_commands = ["finish", "step_out"]
if self.command not in valid_commands:
return False
return True
def __repr__(self):
return f"Then({self.command})"
@staticmethod
def constructor(loader, node):
return Then(loader.construct_scalar(node))
@staticmethod
def representer(dumper, data):
return dumper.represent_scalar("!then", data.command)
@staticmethod
def register_yaml(loader):
yaml.add_constructor("!then", Then.constructor, loader)
yaml.add_representer(Then, Then.representer)
##############
## Utility Nodes: Can be used anywhere in a script as a form of syntactic sugar.

View file

@ -20,6 +20,7 @@ from dex.test_script.Nodes import (
Expect,
FileLabels,
Where,
Then,
setup_yaml_parser,
)
@ -176,7 +177,10 @@ class DexterScript:
if result := do(visit_where, key, scope):
return result
new_scope = scope.add_where(key)
if result := self._visit_script(
if isinstance(value, Then):
if result := do(visit_then, value, new_scope):
return result
elif result := self._visit_script(
value, new_scope, visit_where, visit_expect, visit_then
):
return result
@ -191,6 +195,7 @@ class DexterScript:
self,
visit_where: Optional[Callable[[Where, Scope], Any]] = None,
visit_expect: Optional[Callable[[Expect, Any, Scope], Any]] = None,
visit_then: Optional[Callable[[Then, Scope], Any]] = None,
) -> Any:
"""Visits all nodes in the script in pre-order traversal, calling any non-none provided visitor functions for
each respective node type. Note that we do not visit expected values independently of their associated expect;
@ -199,7 +204,7 @@ class DexterScript:
If any visit function returns a truthy value, traversal will early-exit and this function returns that value;
otherwise, this function returns None."""
return self._visit_script(
self.script_obj, self.root_scope, visit_where, visit_expect
self.script_obj, self.root_scope, visit_where, visit_expect, visit_then
)
@property

View file

@ -0,0 +1,53 @@
// RUN: %dexter_regression_test_cxx_build %s -o %t
// RUN: %dexter_regression_test_run --use-script --skip-evaluate --binary %t \
// RUN: -- %s | FileCheck %s
/// Test that when we use !then finish, we finish the entire test immediately,
/// without observing any more steps afterwards.
void fizz() {}
void buzz() {}
void fizzbuzz() {}
void doFizzbuzz(int N) {
// CHECK: then_finish.cpp([[# @LINE + 1 ]]:12)
for (int I = 1; I < N; ++I) {
// CHECK-COUNT-15: then_finish.cpp([[# @LINE + 1 ]]:9)
if (I % 3 == 0) { // !dex_label loop_top
if (I % 5 == 0)
// CHECK: then_finish.cpp([[# @LINE + 1 ]]:9)
fizzbuzz(); // !dex_label fizzbuzz
else
fizz();
} else if (I % 5 == 0) {
buzz();
}
}
}
/// We'll see main in "Frame 1" at the same step that we exit from; we should
/// not see it (or doFizzbuzz) afterwards.
// CHECK: Frame 1:
// CHECK-NEXT: main
// CHECK-NOT: main
// CHECK-NOT: doFizzbuzz
int main() {
int V = 0;
doFizzbuzz(30); // !dex_label main_start
V = 1;
return 0; // !dex_label main_end
}
/*
---
!where {function: main}:
!where {function: doFizzbuzz}:
!and {lines: !label loop_top}:
!value I: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
!and {lines: !label fizzbuzz}: !then finish
!and {lines: !range [!label main_start, !label main_end]}:
!value V: [0, 1]
!where {lines: !label main_end}:
!value V: 1
...
*/

View file

@ -0,0 +1,53 @@
// RUN: %dexter_regression_test_cxx_build %s -o %t
// RUN: %dexter_regression_test_run --use-script --skip-evaluate --binary %t \
// RUN: -- %s | FileCheck %s
/// Test that when we use !then step_out, we jump out of the current frame, but
/// continue stepping through the frame above.
/// The function `doFizzbuzz` loops N-1 times before exiting, but in the 15th
/// iteration we hit `!then step_out`. This test looks at the debug output from
/// Dexter's stepping, and checks that we step on the first `if` in the loop
/// 15 times, then we step on the `fizzbuzz` line once, and then we see no more
/// steps inside `doFizzbuzz` afterwards, and finally we step on the last line
/// in `main`, verifying that Dexter has stepped out of the function.
void fizz() {}
void buzz() {}
void fizzbuzz() {}
void doFizzbuzz(int N) {
// CHECK: then_step_out.cpp([[# @LINE + 1 ]]:12)
for (int I = 1; I < N; ++I) {
// CHECK-COUNT-15: then_step_out.cpp([[# @LINE + 1 ]]:9)
if (I % 3 == 0) { // !dex_label loop_top
if (I % 5 == 0)
// CHECK: then_step_out.cpp([[# @LINE + 1 ]]:9)
fizzbuzz(); // !dex_label fizzbuzz
else
fizz();
} else if (I % 5 == 0) {
buzz();
}
}
}
// CHECK-NOT: doFizzbuzz
int main() {
int V = 0;
doFizzbuzz(30); // !dex_label main_start
V = 1;
// CHECK: then_step_out.cpp([[# @LINE + 1 ]]:3)
return 0; // !dex_label main_end
}
/*
---
!where {function: main}:
!where {function: doFizzbuzz}:
!and {lines: !label loop_top}:
!value I: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
!and {lines: !label fizzbuzz}: !then step_out
!and {lines: !range [!label main_start, !label main_end]}:
!value V: [0, 1]
...
*/

View file

@ -22,3 +22,9 @@ CHECK-NEXT: Script starting line [[# @LINE + 1 ]]
!where {function: foo}:
- !value x: 5
...
CHECK-NEXT: Script starting line [[# @LINE + 1 ]]
---
# CHECK-NEXT: Invalid !then command "bar"
!where {function: foo}: !then bar
...

View file

@ -6,6 +6,8 @@ CHECK: ? !where {function: foo}
CHECK-NEXT: : !value 'x': 5
CHECK-NEXT: ? !where {file: lib.cpp, lines: !range [10, 20]}
CHECK-NEXT: : !value 'y': 10
CHECK-NEXT: ? !and {lines: 14}
CHECK-NEXT: : !then 'finish'
CHECK-NEXT: ? !where {lines: 5}
CHECK-NEXT: : ? !and {lines: 5}
CHECK-NEXT: : !value 'y': 7
@ -17,6 +19,7 @@ CHECK-NEXT: !value 'z': bees
!value x: 5
!where {file: "lib.cpp", lines: !range [10, 20]}:
!value y: 10
!and {lines: 14}: !then finish
!where {lines: 5}:
!and {lines: 5}:
!value y: 7