Skip to content

Commit 20efc75

Browse files
ptrthomasclaude
andcommitted
js: branch-outcome and comparison events for coverage/debug tooling
EventType.BRANCH fires after a conditional construct (if / ternary / logical short-circuit / nullish / switch case) decides which arm to take, carrying the boolean outcome. EventType.COMPARE fires when a comparison operator evaluates, carrying the concrete lhs/op/rhs operands. Event gains a nullable value slot for these payloads. Both fire only when a listener is registered - zero overhead unobserved, matching the existing evalExpr guard pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 70932ea commit 20efc75

5 files changed

Lines changed: 212 additions & 5 deletions

File tree

karate-js/src/main/java/io/karatelabs/js/CoreContext.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,13 @@ void event(EventType type, Node node) {
134134
}
135135
}
136136

137+
void event(EventType type, Node node, Object value) {
138+
if (root.listener != null) {
139+
Event event = new Event(type, this, node, value);
140+
root.listener.onEvent(event);
141+
}
142+
}
143+
137144
// public api ======================================================================================================
138145
//
139146
final CoreContext parent;

karate-js/src/main/java/io/karatelabs/js/Event.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,22 @@ public class Event {
3030
public final EventType type;
3131
public final Context context;
3232
public final Node node;
33+
/**
34+
* Optional payload, present only for event types that carry one —
35+
* {@link EventType#BRANCH} (a {@link Boolean} outcome) and
36+
* {@link EventType#COMPARE} (an {@code Object[]} of lhs / operator / rhs).
37+
*/
38+
public final Object value;
3339

3440
Event(EventType type, Context context, Node node) {
41+
this(type, context, node, null);
42+
}
43+
44+
Event(EventType type, Context context, Node node, Object value) {
3545
this.type = type;
3646
this.context = context;
3747
this.node = node;
48+
this.value = value;
3849
}
3950

4051
}

karate-js/src/main/java/io/karatelabs/js/EventType.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ public enum EventType {
3030
STATEMENT_ENTER,
3131
STATEMENT_EXIT,
3232
EXPRESSION_ENTER,
33-
EXPRESSION_EXIT
33+
EXPRESSION_EXIT,
34+
/**
35+
* Fired after a conditional construct (if / ternary / logical
36+
* short-circuit / switch case) decides which arm to take. The event
37+
* {@code value} is the {@link Boolean} outcome. Useful for coverage
38+
* and debugger tooling.
39+
*/
40+
BRANCH,
41+
/**
42+
* Fired when a comparison operator evaluates. The event {@code value}
43+
* is an {@code Object[]} of {@code [lhs, operator, rhs]} with the
44+
* concrete operand values. Useful for coverage and debugger tooling.
45+
*/
46+
COMPARE
3447

3548
}

karate-js/src/main/java/io/karatelabs/js/Interpreter.java

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1461,7 +1461,9 @@ private static Object evalIfStmt(Node node, CoreContext context) {
14611461
if (context.isStopped()) {
14621462
return null;
14631463
}
1464-
if (Terms.isTruthy(condition)) {
1464+
boolean taken = Terms.isTruthy(condition);
1465+
context.event(EventType.BRANCH, node, taken);
1466+
if (taken) {
14651467
return eval(node.get(4), context);
14661468
} else {
14671469
if (node.size() > 5) {
@@ -1785,6 +1787,9 @@ private static boolean evalLogicExpr(Node node, CoreContext context) {
17851787
return false;
17861788
}
17871789
TokenType logicOp = node.get(1).token.type;
1790+
if (context.root.listener != null) {
1791+
context.event(EventType.COMPARE, node, new Object[]{lhs, node.get(1).getText(), rhs});
1792+
}
17881793
if (Terms.NAN.equals(lhs) || Terms.NAN.equals(rhs)) {
17891794
if (logicOp == NOT_EQ || logicOp == NOT_EQ_EQ) {
17901795
return true; // NaN is not equal to anything, including itself
@@ -1811,6 +1816,7 @@ private static Object evalLogicAndExpr(Node node, CoreContext context) {
18111816
return null;
18121817
}
18131818
boolean lhs = Terms.isTruthy(lhsValue);
1819+
context.event(EventType.BRANCH, node, lhs);
18141820
if (node.get(1).token.type == AMP_AMP) {
18151821
if (lhs) {
18161822
return eval(node.get(2), context);
@@ -1833,7 +1839,9 @@ private static Object evalLogicNullishExpr(Node node, CoreContext context) {
18331839
return null;
18341840
}
18351841
// ?? returns lhs if it's not null/undefined, otherwise rhs
1836-
if (lhsValue == null || lhsValue == Terms.UNDEFINED) {
1842+
boolean defined = lhsValue != null && lhsValue != Terms.UNDEFINED;
1843+
context.event(EventType.BRANCH, node, defined);
1844+
if (!defined) {
18371845
return eval(node.get(2), context);
18381846
}
18391847
return lhsValue;
@@ -1845,7 +1853,9 @@ private static Object evalLogicTernExpr(Node node, CoreContext context) {
18451853
if (context.isStopped()) {
18461854
return null;
18471855
}
1848-
if (Terms.isTruthy(testValue)) {
1856+
boolean taken = Terms.isTruthy(testValue);
1857+
context.event(EventType.BRANCH, node, taken);
1858+
if (taken) {
18491859
return eval(node.get(2), context);
18501860
} else {
18511861
return eval(node.get(4), context);
@@ -2133,7 +2143,9 @@ private static Object evalSwitchStmt(Node node, CoreContext context) {
21332143
if (context.isStopped()) {
21342144
return null;
21352145
}
2136-
if (Terms.eq(switchValue, caseValue, true)) {
2146+
boolean matched = Terms.eq(switchValue, caseValue, true);
2147+
context.event(EventType.BRANCH, caseNode, matched);
2148+
if (matched) {
21372149
found = true;
21382150
}
21392151
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/*
2+
* The MIT License
3+
*
4+
* Copyright 2025 Karate Labs Inc.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
*/
24+
package io.karatelabs.js;
25+
26+
import org.junit.jupiter.api.Test;
27+
28+
import java.util.ArrayList;
29+
import java.util.List;
30+
31+
import static org.junit.jupiter.api.Assertions.*;
32+
33+
/**
34+
* Tests for the {@link EventType#BRANCH} and {@link EventType#COMPARE}
35+
* events used by coverage and debugger tooling.
36+
*/
37+
class ContextEventsTest {
38+
39+
static class EventCollector implements ContextListener {
40+
41+
final List<Event> branches = new ArrayList<>();
42+
final List<Event> compares = new ArrayList<>();
43+
44+
@Override
45+
public void onEvent(Event event) {
46+
if (event.type == EventType.BRANCH) {
47+
branches.add(event);
48+
} else if (event.type == EventType.COMPARE) {
49+
compares.add(event);
50+
}
51+
}
52+
53+
// (line, outcome) pairs for readable assertions, lines 1-based
54+
List<String> branchSummary() {
55+
List<String> list = new ArrayList<>();
56+
for (Event e : branches) {
57+
list.add((e.node.getFirstToken().line + 1) + ":" + e.value);
58+
}
59+
return list;
60+
}
61+
62+
}
63+
64+
private static EventCollector run(String script, Object input) {
65+
Engine engine = new Engine();
66+
EventCollector collector = new EventCollector();
67+
engine.setListener(collector);
68+
engine.put("input", input);
69+
engine.eval(script);
70+
return collector;
71+
}
72+
73+
@Test
74+
void testIfElseBranch() {
75+
String script = "if (input > 5) { 'big' } else { 'small' }";
76+
EventCollector c = run(script, 10);
77+
assertEquals(List.of("1:true"), c.branchSummary());
78+
c = run(script, 3);
79+
assertEquals(List.of("1:false"), c.branchSummary());
80+
}
81+
82+
@Test
83+
void testTernaryBranch() {
84+
String script = "var x = input ? 'a' : 'b'";
85+
EventCollector c = run(script, true);
86+
assertEquals(List.of("1:true"), c.branchSummary());
87+
c = run(script, false);
88+
assertEquals(List.of("1:false"), c.branchSummary());
89+
}
90+
91+
@Test
92+
void testLogicalAndOrBranch() {
93+
String script = "var x = input && 'a';\n"
94+
+ "var y = input || 'b';";
95+
EventCollector c = run(script, true);
96+
assertEquals(List.of("1:true", "2:true"), c.branchSummary());
97+
c = run(script, false);
98+
assertEquals(List.of("1:false", "2:false"), c.branchSummary());
99+
}
100+
101+
@Test
102+
void testNullishBranch() {
103+
String script = "var x = input ?? 'fallback'";
104+
EventCollector c = run(script, "value");
105+
assertEquals(List.of("1:true"), c.branchSummary());
106+
c = run(script, null);
107+
assertEquals(List.of("1:false"), c.branchSummary());
108+
}
109+
110+
@Test
111+
void testSwitchBranch() {
112+
String script = "switch (input) {\n"
113+
+ "case 'a': 1; break;\n"
114+
+ "case 'b': 2; break;\n"
115+
+ "default: 3\n"
116+
+ "}";
117+
EventCollector c = run(script, "b");
118+
// first case misses, second matches — no further case tests
119+
assertEquals(List.of("2:false", "3:true"), c.branchSummary());
120+
c = run(script, "z");
121+
assertEquals(List.of("2:false", "3:false"), c.branchSummary());
122+
}
123+
124+
@Test
125+
void testCompareOperands() {
126+
EventCollector c = run("input >= 25", 22);
127+
assertEquals(1, c.compares.size());
128+
Object[] operands = (Object[]) c.compares.get(0).value;
129+
assertEquals(22, operands[0]);
130+
assertEquals(">=", operands[1]);
131+
assertEquals(25, operands[2]);
132+
}
133+
134+
@Test
135+
void testCompareOperators() {
136+
String script = "input < 1; input > 2; input <= 3; input >= 4; input == 5; input === 6; input != 7; input !== 8;";
137+
EventCollector c = run(script, 5);
138+
assertEquals(8, c.compares.size());
139+
List<String> ops = new ArrayList<>();
140+
for (Event e : c.compares) {
141+
ops.add((String) ((Object[]) e.value)[1]);
142+
}
143+
assertEquals(List.of("<", ">", "<=", ">=", "==", "===", "!=", "!=="), ops);
144+
}
145+
146+
@Test
147+
void testBranchInsideCallbackFiresPerInvocation() {
148+
String script = "var count = 0;\n"
149+
+ "[1, 2, 3].forEach(function(x){ if (x > 1) count++ });\n"
150+
+ "count";
151+
EventCollector c = run(script, null);
152+
// one BRANCH per callback invocation, same site
153+
assertEquals(List.of("2:false", "2:true", "2:true"), c.branchSummary());
154+
assertEquals(3, c.compares.size());
155+
}
156+
157+
@Test
158+
void testNoListenerNothingBreaks() {
159+
Engine engine = new Engine();
160+
Object result = engine.eval("var x = 5 > 3 ? (1 && 2) : 0; x");
161+
assertEquals(2, result);
162+
}
163+
164+
}

0 commit comments

Comments
 (0)