Skip to content

Commit 293faf1

Browse files
ChinmayMadeshicopybara-github
authored andcommitted
Setup of the coverage index.
PiperOrigin-RevId: 805684099
1 parent 0bb2f72 commit 293faf1

15 files changed

Lines changed: 551 additions & 19 deletions

File tree

parser/src/main/java/dev/cel/parser/CelUnparserVisitor.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ public String unparse() {
6666
return stringBuilder.toString();
6767
}
6868

69+
public String unparse(CelExpr expr) {
70+
visit(expr);
71+
return stringBuilder.toString();
72+
}
73+
6974
private static String maybeQuoteField(String field) {
7075
if (RESTRICTED_FIELD_NAMES.contains(field)
7176
|| !IDENTIFIER_SEGMENT_PATTERN.matcher(field).matches()) {

testing/src/main/java/dev/cel/testing/testrunner/BUILD.bazel

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ java_library(
1515
],
1616
deps = [
1717
":annotations",
18+
":cel_coverage_index",
1819
":cel_test_suite",
1920
":cel_test_suite_exception",
2021
":cel_test_suite_text_proto_parser",
@@ -33,7 +34,10 @@ java_library(
3334
srcs = ["JUnitXmlReporter.java"],
3435
tags = [
3536
],
36-
deps = ["@maven//:com_google_guava_guava"],
37+
deps = [
38+
":cel_coverage_index",
39+
"@maven//:com_google_guava_guava",
40+
],
3741
)
3842

3943
java_library(
@@ -42,6 +46,7 @@ java_library(
4246
tags = [
4347
],
4448
deps = [
49+
":cel_coverage_index",
4550
":cel_expression_source",
4651
":cel_test_context",
4752
":cel_test_suite",
@@ -50,12 +55,30 @@ java_library(
5055
],
5156
)
5257

58+
java_library(
59+
name = "cel_coverage_index",
60+
srcs = ["CelCoverageIndex.java"],
61+
tags = [
62+
],
63+
deps = [
64+
"//common:cel_ast",
65+
"//common/ast",
66+
"//common/navigation",
67+
"//common/types:type_providers",
68+
"//parser:unparser_visitor",
69+
"//runtime:evaluation_listener",
70+
"@maven//:com_google_errorprone_error_prone_annotations",
71+
"@maven//:com_google_guava_guava",
72+
],
73+
)
74+
5375
java_library(
5476
name = "test_runner_library",
5577
srcs = ["TestRunnerLibrary.java"],
5678
tags = [
5779
],
5880
deps = [
81+
":cel_coverage_index",
5982
":cel_expression_source",
6083
":cel_test_context",
6184
":cel_test_suite",
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
package dev.cel.testing.testrunner;
15+
16+
import static com.google.common.collect.ImmutableList.toImmutableList;
17+
18+
import javax.annotation.concurrent.ThreadSafe;
19+
import dev.cel.common.CelAbstractSyntaxTree;
20+
import dev.cel.common.ast.CelExpr;
21+
import dev.cel.common.ast.CelExpr.ExprKind;
22+
import dev.cel.common.navigation.CelNavigableAst;
23+
import dev.cel.common.navigation.CelNavigableExpr;
24+
import dev.cel.common.types.CelKind;
25+
import dev.cel.parser.CelUnparserVisitor;
26+
import dev.cel.runtime.CelEvaluationListener;
27+
import java.util.ArrayList;
28+
import java.util.HashMap;
29+
import java.util.List;
30+
import java.util.Map;
31+
import java.util.logging.Logger;
32+
33+
/**
34+
* A class for managing the coverage index for CEL tests.
35+
*
36+
* <p>This class is used to manage the coverage index for CEL tests. It provides a method for
37+
* getting the coverage index for a given test case.
38+
*/
39+
final class CelCoverageIndex {
40+
private static final Logger logger = Logger.getLogger(CelCoverageIndex.class.getName());
41+
42+
private CelAbstractSyntaxTree ast;
43+
private final Map<Long, NodeCoverageStats> nodeCoverageStatsMap = new HashMap<>();
44+
45+
public void setAst(CelAbstractSyntaxTree ast) {
46+
this.ast = ast;
47+
CelNavigableExpr.fromExpr(ast.getExpr())
48+
.allNodes()
49+
.forEach(
50+
celNavigableExpr -> {
51+
NodeCoverageStats nodeCoverageStats = new NodeCoverageStats();
52+
nodeCoverageStats.isBooleanNode = inferBooleanNodeType(celNavigableExpr.expr());
53+
nodeCoverageStatsMap.put(celNavigableExpr.id(), nodeCoverageStats);
54+
});
55+
}
56+
57+
public CelEvaluationListener getEvaluationListener() {
58+
return new EvaluationListener(this, nodeCoverageStatsMap);
59+
}
60+
61+
public CoverageReport getCoverageReport() {
62+
CoverageReport report = new CoverageReport();
63+
traverseAndCalculateCoverage(
64+
CelNavigableAst.fromAst(ast).getRoot(), nodeCoverageStatsMap, true, "", report);
65+
report.celExpr = new CelUnparserVisitor(ast).unparse(ast.getExpr());
66+
logger.info("CEL Expression: " + report.celExpr);
67+
logger.info("Nodes: " + report.nodes);
68+
logger.info("Covered Nodes: " + report.coveredNodes);
69+
logger.info("Branches: " + report.branches);
70+
logger.info("Covered Boolean Outcomes: " + report.coveredBooleanOutcomes);
71+
logger.info("Unencountered Nodes: " + report.unencounteredNodes);
72+
logger.info("Unencountered Branches: " + report.unencounteredBranches);
73+
return report;
74+
}
75+
76+
public static final class CoverageReport {
77+
String celExpr;
78+
long nodes = 0L;
79+
long coveredNodes = 0L;
80+
long branches = 0L;
81+
long coveredBooleanOutcomes = 0L;
82+
List<String> unencounteredNodes = new ArrayList<>();
83+
List<String> unencounteredBranches = new ArrayList<>();
84+
}
85+
86+
/** A class for managing the coverage stats for a CEL node. */
87+
private static final class NodeCoverageStats {
88+
Boolean isBooleanNode = false;
89+
Boolean covered = false;
90+
Boolean hasTrueBranch = false;
91+
Boolean hasFalseBranch = false;
92+
}
93+
94+
private Boolean inferBooleanNodeType(CelExpr celExpr) {
95+
return ast.getTypeMap().containsKey(celExpr.id())
96+
&& ast.getTypeMap().get(celExpr.id()).kind().equals(CelKind.BOOL);
97+
}
98+
99+
private void traverseAndCalculateCoverage(
100+
CelNavigableExpr node,
101+
Map<Long, NodeCoverageStats> statsMap,
102+
boolean logUnencountered,
103+
String precedingTabs,
104+
CoverageReport report) {
105+
long nodeId = node.id();
106+
NodeCoverageStats stats = statsMap.getOrDefault(nodeId, new NodeCoverageStats());
107+
report.nodes++;
108+
109+
boolean isInterestingBooleanNode =
110+
stats.isBooleanNode
111+
&& !node.expr().getKind().equals(ExprKind.Kind.CONSTANT)
112+
&& !(node.expr().getKind().equals(ExprKind.Kind.CALL)
113+
&& node.expr().call().function().equals("cel.@block"));
114+
115+
// Only unparse if the node is interesting (boolean node) and we need to log
116+
// unencountered nodes.
117+
String exprText = "";
118+
if (isInterestingBooleanNode && logUnencountered) {
119+
exprText = new CelUnparserVisitor(ast).unparse(node.expr());
120+
}
121+
122+
boolean nodeCovered = stats.covered;
123+
if (nodeCovered) {
124+
report.coveredNodes++;
125+
} else if (logUnencountered) {
126+
if (isInterestingBooleanNode) {
127+
report.unencounteredNodes.add(String.format("Expression %s", exprText));
128+
}
129+
logUnencountered = false;
130+
}
131+
132+
if (isInterestingBooleanNode) {
133+
report.branches += 2;
134+
if (stats.hasTrueBranch) {
135+
report.coveredBooleanOutcomes++;
136+
} else if (logUnencountered) {
137+
report.unencounteredBranches.add(
138+
String.format("%sExpression %s: Never evaluated to 'true'", precedingTabs, exprText));
139+
precedingTabs += "\t";
140+
}
141+
if (stats.hasFalseBranch) {
142+
report.coveredBooleanOutcomes++;
143+
} else if (logUnencountered) {
144+
report.unencounteredBranches.add(
145+
String.format("%sExpression %s: Never evaluated to 'false'", precedingTabs, exprText));
146+
precedingTabs += "\t";
147+
}
148+
}
149+
150+
for (CelNavigableExpr child : node.children().collect(toImmutableList())) {
151+
traverseAndCalculateCoverage(child, statsMap, logUnencountered, precedingTabs, report);
152+
}
153+
}
154+
155+
@ThreadSafe
156+
private static final class EvaluationListener implements CelEvaluationListener {
157+
@ThreadSafe.Suppress(
158+
reason =
159+
"This map is only accessed within a single execution at a time, so no race conditions"
160+
+ " will occur.")
161+
private final Map<Long, NodeCoverageStats> nodeCoverageStatsMap;
162+
163+
@ThreadSafe.Suppress(reason = "CelCoverageIndex is effectively thread-safe for this usage.")
164+
private final CelCoverageIndex celCoverageIndex;
165+
166+
EvaluationListener(
167+
CelCoverageIndex celCoverageIndex, Map<Long, NodeCoverageStats> nodeCoverageStatsMap) {
168+
this.celCoverageIndex = celCoverageIndex;
169+
this.nodeCoverageStatsMap = nodeCoverageStatsMap;
170+
}
171+
172+
@Override
173+
public void callback(CelExpr celExpr, Object evaluationResult) {
174+
NodeCoverageStats nodeCoverageStats =
175+
nodeCoverageStatsMap.getOrDefault(celExpr.id(), new NodeCoverageStats());
176+
nodeCoverageStats.covered = true;
177+
nodeCoverageStats.isBooleanNode = celCoverageIndex.inferBooleanNodeType(celExpr);
178+
if (nodeCoverageStats.isBooleanNode) {
179+
if (evaluationResult instanceof Boolean) {
180+
if ((Boolean) evaluationResult) {
181+
nodeCoverageStats.hasTrueBranch = true;
182+
} else {
183+
nodeCoverageStats.hasFalseBranch = true;
184+
}
185+
}
186+
}
187+
nodeCoverageStatsMap.put(celExpr.id(), nodeCoverageStats);
188+
}
189+
}
190+
}

testing/src/main/java/dev/cel/testing/testrunner/CelUserTestTemplate.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package dev.cel.testing.testrunner;
1616

1717
import dev.cel.testing.testrunner.CelTestSuite.CelTestSection.CelTestCase;
18+
import org.jspecify.annotations.Nullable;
1819
import org.junit.Test;
1920
import org.junit.runner.RunWith;
2021
import org.junit.runners.Parameterized;
@@ -27,7 +28,12 @@
2728
@RunWith(Parameterized.class)
2829
public abstract class CelUserTestTemplate {
2930

30-
@Parameter public CelTestCase testCase;
31+
@Parameter(0)
32+
public CelTestCase testCase;
33+
34+
@Parameter(1)
35+
public @Nullable CelCoverageIndex celCoverageIndex;
36+
3137
private final CelTestContext celTestContext;
3238

3339
public CelUserTestTemplate(CelTestContext celTestContext) {
@@ -36,7 +42,11 @@ public CelUserTestTemplate(CelTestContext celTestContext) {
3642

3743
@Test
3844
public void test() throws Exception {
39-
TestRunnerLibrary.runTest(testCase, updateCelTestContext(celTestContext));
45+
if (celCoverageIndex != null) {
46+
TestRunnerLibrary.runTest(testCase, updateCelTestContext(celTestContext), celCoverageIndex);
47+
} else {
48+
TestRunnerLibrary.runTest(testCase, updateCelTestContext(celTestContext));
49+
}
4050
}
4151

4252
/**

testing/src/main/java/dev/cel/testing/testrunner/JUnitXmlReporter.java

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import javax.xml.transform.TransformerFactory;
3232
import javax.xml.transform.dom.DOMSource;
3333
import javax.xml.transform.stream.StreamResult;
34+
import org.jspecify.annotations.Nullable;
3435
import org.w3c.dom.Document;
3536
import org.w3c.dom.Element;
3637
import org.w3c.dom.Text;
@@ -70,9 +71,13 @@ void onStart(TestContext context) {
7071
testContext = context;
7172
}
7273

73-
/** Called after all tests are run */
7474
void onFinish() {
75-
generateReport();
75+
generateReport(null);
76+
}
77+
78+
/** Called after all tests are run */
79+
void onFinish(CelCoverageIndex.@Nullable CoverageReport coverageReport) {
80+
generateReport(coverageReport);
7681
}
7782

7883
/** Returns the number of failed tests */
@@ -84,7 +89,7 @@ int getNumFailed() {
8489
* Generates junit equivalent xml report that sponge/fusion can understand. Called after all tests
8590
* are run
8691
*/
87-
void generateReport() {
92+
void generateReport(CelCoverageIndex.@Nullable CoverageReport coverageReport) {
8893
try {
8994
DocumentBuilderFactory docBuilderFactory = DocumentBuilderFactory.newInstance();
9095
DocumentBuilder docBuilder = docBuilderFactory.newDocumentBuilder();
@@ -123,6 +128,48 @@ void generateReport() {
123128
prevSuite = currentSuite;
124129
currentSuite = doc.createElement(XmlConstants.TESTSUITE);
125130
rootElement.appendChild(currentSuite);
131+
if (coverageReport != null) {
132+
if (coverageReport.nodes == 0) {
133+
currentSuite.setAttribute(XmlConstants.ATTR_CEL_COVERAGE, "No coverage stats found");
134+
} else {
135+
// CEL expression
136+
currentSuite.setAttribute(XmlConstants.ATTR_CEL_EXPR, coverageReport.celExpr);
137+
// Node coverage
138+
double nodeCoverage =
139+
(double) coverageReport.coveredNodes / (double) coverageReport.nodes * 100.0;
140+
String nodeCoverageString =
141+
String.format(
142+
"%.2f%% (%d out of %d nodes covered)",
143+
nodeCoverage, coverageReport.coveredNodes, coverageReport.nodes);
144+
currentSuite.setAttribute(XmlConstants.ATTR_AST_NODE_COVERAGE, nodeCoverageString);
145+
if (!coverageReport.unencounteredNodes.isEmpty()) {
146+
currentSuite.setAttribute(
147+
XmlConstants.ATTR_INTERESTING_UNENCOUNTERED_NODES,
148+
String.join("\n", coverageReport.unencounteredNodes));
149+
}
150+
// Branch coverage
151+
double branchCoverage = 0.0;
152+
if (coverageReport.branches > 0) {
153+
branchCoverage =
154+
(double) coverageReport.coveredBooleanOutcomes
155+
/ (double) coverageReport.branches
156+
* 100.0;
157+
}
158+
String branchCoverageString =
159+
String.format(
160+
"%.2f%% (%d out of %d branch outcomes covered)",
161+
branchCoverage,
162+
coverageReport.coveredBooleanOutcomes,
163+
coverageReport.branches);
164+
currentSuite.setAttribute(
165+
XmlConstants.ATTR_AST_BRANCH_COVERAGE, branchCoverageString);
166+
if (!coverageReport.unencounteredBranches.isEmpty()) {
167+
currentSuite.setAttribute(
168+
XmlConstants.ATTR_INTERESTING_UNENCOUNTERED_BRANCH_PATHS,
169+
String.join("\n", coverageReport.unencounteredBranches));
170+
}
171+
}
172+
}
126173
currentSuite.setAttribute(XmlConstants.ATTR_NAME, tr.getTestClassName());
127174
if (prevSuite != null) {
128175
prevSuite.setAttribute(XmlConstants.ATTR_TESTS, "" + testsInSuite);
@@ -226,5 +273,13 @@ private static final class XmlConstants {
226273
static final String ATTR_TYPE = "type";
227274
static final String ATTR_MESSAGE = "message";
228275
static final String ATTR_CLASSNAME = "classname";
276+
// Coverage attributes.
277+
static final String ATTR_CEL_EXPR = "Cel_Expr";
278+
static final String ATTR_CEL_COVERAGE = "Cel_Coverage";
279+
static final String ATTR_AST_NODE_COVERAGE = "Ast_Node_Coverage";
280+
static final String ATTR_INTERESTING_UNENCOUNTERED_NODES = "Interesting_Unencountered_Nodes";
281+
static final String ATTR_AST_BRANCH_COVERAGE = "Ast_Branch_Coverage";
282+
static final String ATTR_INTERESTING_UNENCOUNTERED_BRANCH_PATHS =
283+
"Interesting_Unencountered_Branch_Paths";
229284
}
230285
}

0 commit comments

Comments
 (0)