Skip to content

Commit b89ff6a

Browse files
Support JUnit XML aggregate reports for Pkl test (#93)
* Support junit xml output for pkl test * Switch from integration bazel test to pytest Co-authored-by: Kushal Pisavadia <[email protected]> * Update pkl/private/pkl.bzl Co-authored-by: Kushal Pisavadia <[email protected]> * fix python toolchain resolution order issue --------- Co-authored-by: Kushal Pisavadia <[email protected]> Co-authored-by: Kushal Pisavadia <[email protected]>
1 parent 554ca54 commit b89ff6a

File tree

6 files changed

+237
-4
lines changed

6 files changed

+237
-4
lines changed

MODULE.bazel

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ bazel_dep(name = "rules_shell", version = "0.4.0")
3030

3131
bazel_dep(name = "buildifier_prebuilt", version = "7.3.1", dev_dependency = True)
3232
bazel_dep(name = "gazelle", version = "0.44.0", dev_dependency = True, repo_name = "bazel_gazelle")
33-
bazel_dep(name = "rules_bazel_integration_test", version = "0.29.0", dev_dependency = True)
3433
bazel_dep(name = "rules_python", version = "1.4.1", dev_dependency = True)
3534
bazel_dep(name = "stardoc", version = "0.8.0", dev_dependency = True)
3635

@@ -98,6 +97,10 @@ python.toolchain(
9897
python_version = "3.12",
9998
)
10099

100+
# moved here due to python toolchain resolution order issue.
101+
# rules_bazel_integration_test needs to be declared after python.toolchain
102+
bazel_dep(name = "rules_bazel_integration_test", version = "0.29.0", dev_dependency = True)
103+
101104
bazel_binaries = use_extension(
102105
"@rules_bazel_integration_test//:extensions.bzl",
103106
"bazel_binaries",

pkl/private/pkl.bzl

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ def _prepare_pkl_script(ctx, is_test_target):
8282
ctx.actions.write(output = symlinks_json_file, content = json.encode(path_to_symlink_target))
8383
pkl_symlink_tool = pkl_toolchain.symlink_tool[DefaultInfo].files_to_run.executable
8484

85+
suite_name_parts = []
86+
if is_test_target:
87+
package_parts = [part for part in ctx.label.package.split("/") if part != ""]
88+
suite_name_parts = package_parts + [ctx.label.name]
89+
8590
# The 'args' lists for 'pkl_eval' and 'pkl_test' differ because for `pkl_eval`, files are passed as file targets to enable
8691
# path stripping on the `ctx.Args` object when using the '--experimental_output_path=strip' flag. Currently, test rules
8792
# don't support using the `ctx.Args` object, which will be addressed by the following upstream PR
@@ -95,6 +100,7 @@ def _prepare_pkl_script(ctx, is_test_target):
95100
ctx.attr.multiple_outputs,
96101
working_dir,
97102
cache_root_path if len(caches) else "",
103+
".".join(suite_name_parts) if is_test_target else "",
98104
]
99105

100106
for k, v in ctx.attr.properties.items():

pkl/private/run_pkl_script.sh

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ entrypoints=$7
3434
multiple_outputs=$8
3535
working_dir=$9
3636
cache_dir=${10}
37-
shift 10
37+
suite_name=${11}
38+
shift 11
3839

3940
properties_and_expressions=("$@")
4041

@@ -53,8 +54,14 @@ if [ "$command" == "eval" ]; then
5354
else
5455
output_args=("--output-path" "$expected_output")
5556
fi
57+
test_args=()
5658
elif [[ "$command" == "test" ]]; then
57-
output_args=()
59+
output_args=()
60+
if [[ -n "${XML_OUTPUT_FILE}" ]]; then
61+
test_args=("--junit-reports" "${working_dir}" "--junit-aggregate-reports" "--junit-aggregate-suite-name" "${suite_name}")
62+
else
63+
test_args=()
64+
fi
5865
else
5966
echo "invalid command: $command" >&2
6067
exit 1
@@ -66,9 +73,16 @@ if [[ -n "$cache_dir" ]]; then
6673
cache_args=("--cache-dir" "../cache")
6774
fi
6875

69-
output=$($executable "$command" $format_args "${properties_and_expressions[@]}" $expression_args --working-dir "${working_dir}" "${cache_args[@]}" "${output_args[@]}" $entrypoints)
76+
output=$($executable "$command" $format_args "${properties_and_expressions[@]}" $expression_args --working-dir "${working_dir}" "${cache_args[@]}" "${test_args[@]}" "${output_args[@]}" $entrypoints)
7077

7178
ret=$?
79+
80+
if [[ "$command" == "test" ]]; then
81+
if [ -f "${working_dir}/${suite_name}.xml" ]; then
82+
mv "${working_dir}/${suite_name}.xml" "${XML_OUTPUT_FILE}"
83+
fi
84+
fi
85+
7286
if [[ $ret != 0 ]]; then
7387
if [ "$command" == eval ]; then
7488
echo "Failed processing PKL configuration with entrypoint(s) '$entrypoints' (PWD: $(pwd)):" >&2

tests/junit_xml/BUILD.bazel

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Copyright © 2025 Apple Inc. and the Pkl project authors. All rights reserved.
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+
15+
load("@rules_pkl//pkl:defs.bzl", "pkl_test")
16+
load("@rules_python//python:defs.bzl", "py_test")
17+
18+
# Sample pkl test for XML generation
19+
pkl_test(
20+
name = "sample_xml_generator",
21+
srcs = ["sample_xml_test.pkl"],
22+
)
23+
24+
# Python unittest that runs pkl_test with XML output and validates the XML content
25+
py_test(
26+
name = "junit_xml_validation_test",
27+
srcs = ["junit_xml_validation_test.py"],
28+
data = [":sample_xml_generator"],
29+
env = {
30+
"SAMPLE_XML_GENERATOR_PATH": "$(location :sample_xml_generator)",
31+
},
32+
)
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
#!/usr/bin/env python3
2+
# Copyright © 2025 Apple Inc. and the Pkl project authors. All rights reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# https://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Test that validates the JUnit XML output from pkl_test."""
17+
18+
import os
19+
import subprocess
20+
import sys
21+
import tempfile
22+
import unittest
23+
import xml.etree.ElementTree as ET
24+
from functools import cached_property
25+
from pathlib import Path
26+
27+
28+
class JUnitXMLValidationTest(unittest.TestCase):
29+
@classmethod
30+
def setUpClass(cls):
31+
with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as xml_file:
32+
cls.xml_file = Path(xml_file.name)
33+
34+
# Set environment and get script path
35+
env = os.environ | {"XML_OUTPUT_FILE": str(cls.xml_file)}
36+
script_path = os.environ.get("SAMPLE_XML_GENERATOR_PATH")
37+
38+
if not script_path:
39+
raise RuntimeError("SAMPLE_XML_GENERATOR_PATH environment variable not set")
40+
41+
# Run the pkl_test with XML output enabled
42+
try:
43+
result = subprocess.run(
44+
[script_path], env=env, capture_output=True, text=True, check=True
45+
)
46+
except subprocess.CalledProcessError as e:
47+
raise RuntimeError(
48+
f"""Failed to run pkl_test: {e}
49+
STDOUT:
50+
{result.stdout}
51+
STDERR:
52+
{result.stderr}
53+
"""
54+
) from e
55+
56+
if not cls.xml_file.exists():
57+
raise RuntimeError(f"XML output file was not created at {cls.xml_file}")
58+
59+
@cached_property
60+
def xml_root(self):
61+
return ET.parse(self.xml_file).getroot()
62+
63+
def test_xml_file_exists_and_parseable(self):
64+
self.assertTrue(
65+
self.xml_file.exists(), f"XML file {self.xml_file} does not exist"
66+
)
67+
68+
try:
69+
self.assertIsNotNone(self.xml_root)
70+
except ET.ParseError as e:
71+
self.fail(f"Failed to parse XML: {e}")
72+
73+
def test_root_element_structure(self):
74+
root = self.xml_root
75+
76+
self.assertEqual(
77+
root.tag,
78+
"testsuites",
79+
f"Root element should be 'testsuites', got '{root.tag}'",
80+
)
81+
82+
required_attrs = ["name", "tests", "failures"]
83+
missing_attrs = [attr for attr in required_attrs if attr not in root.attrib]
84+
self.assertFalse(
85+
missing_attrs, f"testsuites missing attributes: {missing_attrs}"
86+
)
87+
88+
self.assertEqual(root.attrib["name"], "tests.junit_xml.sample_xml_generator")
89+
90+
def test_testsuite_structure(self):
91+
testsuites = self.xml_root.findall("testsuite")
92+
self.assertGreater(len(testsuites), 0, "No testsuite elements found")
93+
94+
required_attrs = ["name", "tests", "failures"]
95+
for i, testsuite in enumerate(testsuites):
96+
missing_attrs = [
97+
attr for attr in required_attrs if attr not in testsuite.attrib
98+
]
99+
self.assertFalse(
100+
missing_attrs, f"testsuite {i} missing attributes: {missing_attrs}"
101+
)
102+
103+
def test_testcase_structure(self):
104+
testcases = self.xml_root.findall(".//testcase")
105+
self.assertGreater(len(testcases), 0, "No testcase elements found")
106+
107+
required_attrs = ["name", "classname"]
108+
for i, testcase in enumerate(testcases):
109+
missing_attrs = [
110+
attr for attr in required_attrs if attr not in testcase.attrib
111+
]
112+
self.assertFalse(
113+
missing_attrs, f"testcase {i} missing attributes: {missing_attrs}"
114+
)
115+
116+
def test_expected_test_cases(self):
117+
testcases = self.xml_root.findall(".//testcase")
118+
testcase_names = {tc.attrib["name"] for tc in testcases}
119+
expected_names = {"dummy test line item 1", "dummy test line item 2"}
120+
missing_names = expected_names - testcase_names
121+
self.assertFalse(
122+
missing_names, f"Expected test cases not found: {missing_names}"
123+
)
124+
125+
def test_xml_declaration(self):
126+
content = self.xml_file.read_text()
127+
self.assertIn("<?xml version", content, "XML declaration not found")
128+
129+
def test_suite_name_matches_target(self):
130+
expected_name = "tests.junit_xml.sample_xml_generator"
131+
self.assertEqual(
132+
self.xml_root.attrib["name"],
133+
expected_name,
134+
"Root testsuites name should match target path",
135+
)
136+
137+
138+
if __name__ == "__main__":
139+
unittest.main()
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the Pkl project authors. All rights reserved.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
// Copyright © 2025 Apple Inc. and the Pkl project authors. All rights reserved.
18+
//
19+
// Licensed under the Apache License, Version 2.0 (the "License");
20+
// you may not use this file except in compliance with the License.
21+
// You may obtain a copy of the License at
22+
//
23+
// https://www.apache.org/licenses/LICENSE-2.0
24+
//
25+
// Unless required by applicable law or agreed to in writing, software
26+
// distributed under the License is distributed on an "AS IS" BASIS,
27+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
28+
// See the License for the specific language governing permissions and
29+
// limitations under the License.
30+
amends "pkl:test"
31+
32+
facts {
33+
["dummy test line item 1"] {
34+
true
35+
}
36+
["dummy test line item 2"] {
37+
true
38+
}
39+
}

0 commit comments

Comments
 (0)