Skip to content

Commit 5e5aa04

Browse files
committed
feat(GHA): distribute tests based on runtime
1 parent d4a6615 commit 5e5aa04

File tree

6 files changed

+247
-17
lines changed

6 files changed

+247
-17
lines changed

.github/workflows/run-tests.yaml

+9-8
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,20 @@ jobs:
2929
matrix:
3030
test-groups:
3131
[
32-
"test/test_[a-e]*",
33-
"test/test_[f-h]*",
34-
"test/test_[i-o,q-r,t-z]*",
35-
"test/test_[p]*",
36-
"test/test_[s]*",
37-
"test/storage/*",
38-
"test/extension/*",
32+
0,
33+
1,
34+
2,
35+
3,
36+
4,
37+
5,
38+
6,
39+
"other"
3940
]
4041
fail-fast: false
4142
steps:
4243
- uses: actions/checkout@v4
4344
- uses: ./.github/actions/setup
44-
- run: ./scripts/ci.sh
45+
- run: python ./scripts/ci.py ./scripts/distribution.json
4546
env:
4647
DISPLAY: ":99.0"
4748
TESTS: ${{ matrix.test-groups }}

junit-report.xml

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<?xml version="1.0" encoding="utf-8"?><testsuites><testsuite name="pytest" errors="0" failures="0" skipped="0" tests="8" time="547.997" timestamp="2024-08-06T23:49:05.138967" hostname="SZ1"><testcase classname="test.test_profile" name="test_profile_recovery[on_crash_during_launch-stateful-without_seed_tar]" time="143.851" /><testcase classname="test.test_profile" name="test_profile_recovery[on_crash_during_launch-stateless-with_seed_tar]" time="138.431" /><testcase classname="test.test_profile" name="test_profile_recovery[on_crash_during_launch-stateful-with_seed_tar]" time="123.576" /><testcase classname="test.test_profile" name="test_profile_saved_when_launch_crashes" time="118.191" /><testcase classname="test.test_simple_commands" name="test_save_screenshot_valid[headless]" time="23.129" /><testcase classname="test.test_profile" name="test_load_tar_file" time="0.261" /><testcase classname="test.test_js_instrument_py" name="test_validate_bad__log_settings_missing" time="0.024" /><testcase classname="test.test_js_instrument_py" name="test_validate_good" time="0.202" /></testsuite></testsuites>

scripts/ci.py

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#!/usr/bin/env python
2+
# Usage: TESTS=0 python scripts/ci.py scripts/distribution.json
3+
import json
4+
import os
5+
import subprocess
6+
from sys import argv
7+
8+
test_selection = os.getenv("TESTS")
9+
assert len(argv) == 2
10+
if test_selection is None:
11+
print("Please set TESTS environment variable")
12+
exit(1)
13+
with open(argv[1]) as f:
14+
test_distribution = json.load(f)
15+
if test_selection == "other":
16+
res = subprocess.run("pytest --collect-only -q", capture_output=True, shell=True)
17+
res.check_returncode()
18+
actual_tests = res.stdout.decode("utf-8").splitlines()
19+
for index, test in enumerate(actual_tests):
20+
if len(test) == 0: # cut off warnings
21+
actual_tests = actual_tests[:index]
22+
23+
all_known_tests = set(sum(test_distribution, []))
24+
actual_tests_set = set(actual_tests)
25+
known_but_dont_exist = all_known_tests.difference(actual_tests_set)
26+
exist_but_arent_known = actual_tests_set.difference(all_known_tests)
27+
if len(known_but_dont_exist) > 0 or len(exist_but_arent_known) > 0:
28+
print("known_but_dont_exist:", known_but_dont_exist)
29+
print("exist_but_arent_known", exist_but_arent_known)
30+
print("Uncovered or outdated tests")
31+
exit(2)
32+
else:
33+
index = int(test_selection)
34+
tests = " ".join('"' + test + '"' for test in test_distribution[index])
35+
subprocess.run(
36+
"pytest "
37+
"--cov=openwpm --junit-xml=junit-report.xml "
38+
f"--cov-report=xml {tests} "
39+
"-s -v --durations=10;",
40+
shell=True,
41+
check=True,
42+
)
43+
subprocess.run("codecov -f coverage.xml", shell=True, check=True)

scripts/ci.sh

-9
This file was deleted.

scripts/distribute_tests.py

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#!/usr/bin/env python
2+
# Usage: python scripts/distribute_tests.py junit-report.xml 7 scripts/distribution.json
3+
# Where junit-report.xml is the result of running
4+
# python -m pytest --cov=openwpm --junit-xml=junit-report.xml --cov-report=xml test/ -s -v --durations=10
5+
import json
6+
from sys import argv
7+
from typing import Any
8+
from xml.etree import ElementTree as ET
9+
10+
assert len(argv) == 4
11+
12+
num_runner = int(argv[2])
13+
tree = ET.parse(argv[1])
14+
root = tree.getroot()
15+
testcases: list[dict[str, Any]] = []
16+
for testcase in root.iter("testcase"):
17+
# Build correct test name based on naming convention
18+
classname = testcase.get("classname")
19+
assert isinstance(classname, str)
20+
split = classname.rsplit(".", 1)
21+
if split[1][0].isupper(): # Test is in a class
22+
split[0] = split[0].replace(".", "/")
23+
split[0] = split[0] + ".py"
24+
path = split[0] + "::" + split[1]
25+
else:
26+
path = classname.replace(".", "/") + ".py"
27+
time = testcase.get("time")
28+
assert isinstance(time, str)
29+
testcases.append({"path": f'{path}::{testcase.get("name")}', "time": float(time)})
30+
31+
sorted_testcases = sorted(testcases, key=lambda x: x["time"], reverse=True)
32+
total_time = sum(k["time"] + 0.5 for k in sorted_testcases)
33+
time_per_runner = total_time / num_runner
34+
print(f"Total time: {total_time} Total time per runner: {total_time / num_runner}")
35+
distributed_testcases: list[list[str]] = [[] for _ in range(num_runner)]
36+
estimated_time = []
37+
for subsection in distributed_testcases:
38+
time_spent = 0
39+
tmp = []
40+
for testcase in sorted_testcases:
41+
if time_spent + testcase["time"] < time_per_runner:
42+
tmp.append(testcase)
43+
time_spent += testcase["time"] + 0.5 # account for overhead per testcase
44+
for testcase in tmp:
45+
sorted_testcases.remove(testcase)
46+
subsection[:] = [testcase["path"] for testcase in tmp]
47+
estimated_time.append(time_spent)
48+
49+
assert len(sorted_testcases) == 0, print(len(sorted_testcases))
50+
print(estimated_time)
51+
with open(argv[3], "w") as f:
52+
json.dump(distributed_testcases, f, indent=0)

scripts/distribution.json

+142
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
[
2+
[
3+
"test/test_profile.py::test_profile_recovery[on_crash_during_launch-stateful-without_seed_tar]",
4+
"test/test_profile.py::test_profile_recovery[on_crash_during_launch-stateless-with_seed_tar]",
5+
"test/test_profile.py::test_profile_recovery[on_crash_during_launch-stateful-with_seed_tar]",
6+
"test/test_profile.py::test_profile_saved_when_launch_crashes",
7+
"test/test_simple_commands.py::test_save_screenshot_valid[headless]",
8+
"test/test_profile.py::test_load_tar_file",
9+
"test/test_js_instrument_py.py::test_validate_bad__log_settings_missing",
10+
"test/test_js_instrument_py.py::test_validate_good"
11+
],
12+
[
13+
"test/test_profile.py::test_profile_recovery[on_crash-stateless-with_seed_tar]",
14+
"test/test_profile.py::test_profile_recovery[on_crash-stateful-with_seed_tar]",
15+
"test/test_profile.py::test_profile_recovery[on_timeout-stateful-with_seed_tar]",
16+
"test/test_profile.py::test_profile_recovery[on_normal_operation-stateless-with_seed_tar]",
17+
"test/test_profile.py::test_profile_recovery[on_crash-stateful-without_seed_tar]",
18+
"test/test_js_instrument_py.py::test_validate_bad__log_settings_invalid",
19+
"test/test_js_instrument_py.py::test_api_collection_fingerprinting",
20+
"test/test_js_instrument_py.py::test_validate_bad__not_a_list",
21+
"test/test_js_instrument_py.py::test_validate_bad__missing_object",
22+
"test/test_js_instrument_py.py::test_validated_bad__missing_instrumentedName",
23+
"test/test_js_instrument_py.py::test_merge_and_validate_multiple_overlap_properties_to_instrument_properties_to_exclude",
24+
"test/storage/test_storage_providers.py::test_basic_access[memory_structured]",
25+
"test/storage/test_storage_providers.py::test_basic_access[memory_arrow]",
26+
"test/storage/test_storage_providers.py::test_basic_access[sqlite]",
27+
"test/test_js_instrument_py.py::test_complete_pass",
28+
"test/storage/test_storage_providers.py::test_basic_unstructured_storing[memory_unstructured]",
29+
"test/test_task_manager.py::test_failure_limit_value"
30+
],
31+
[
32+
"test/test_profile.py::test_profile_recovery[on_timeout-stateful-without_seed_tar]",
33+
"test/test_profile.py::test_profile_recovery[on_timeout-stateless-with_seed_tar]",
34+
"test/test_profile.py::test_profile_recovery[on_normal_operation-stateful-with_seed_tar]",
35+
"test/test_profile.py::test_dump_profile_command",
36+
"test/test_profile.py::test_profile_recovery[on_normal_operation-stateful-without_seed_tar]",
37+
"test/test_extension.py::TestExtension::test_extension_gets_correct_visit_id",
38+
"test/storage/test_storage_providers.py::test_basic_unstructured_storing[leveldb]",
39+
"test/storage/test_storage_providers.py::test_basic_unstructured_storing[local_gzip]",
40+
"test/test_callstack_instrument.py::test_http_stacktrace",
41+
"test/test_dataclass_validations.py::test_display_mode",
42+
"test/test_dataclass_validations.py::test_browser_type",
43+
"test/test_dataclass_validations.py::test_tp_cookies_opt",
44+
"test/test_dataclass_validations.py::test_save_content_type",
45+
"test/test_dataclass_validations.py::test_log_file_extension",
46+
"test/test_dataclass_validations.py::test_failure_limit",
47+
"test/test_dataclass_validations.py::test_num_browser_crawl_config"
48+
],
49+
[
50+
"test/test_task_manager.py::test_assertion_error_propagation[False-expectation0]",
51+
"test/test_xvfb_browser.py::test_display_shutdown",
52+
"test/extension/test_startup_timeout.py::test_extension_startup_timeout",
53+
"test/test_simple_commands.py::test_browse_wrapper_http_table_valid[headless]",
54+
"test/test_task_manager.py::test_assertion_error_propagation[True-expectation1]",
55+
"test/test_simple_commands.py::test_browse_http_table_valid[headless]",
56+
"test/test_simple_commands.py::test_browse_http_table_valid[xvfb]",
57+
"test/test_simple_commands.py::test_browse_wrapper_http_table_valid[xvfb]",
58+
"test/test_extension.py::test_audio_fingerprinting",
59+
"test/test_profile.py::test_profile_error",
60+
"test/test_http_instrumentation.py::TestHTTPInstrument::test_service_worker_requests",
61+
"test/test_js_instrument_py.py::test_merge_and_validate_multiple_overlap_properties",
62+
"test/test_js_instrument_py.py::test_merge_when_log_settings_is_null",
63+
"test/test_webdriver_utils.py::test_parse_neterror"
64+
],
65+
[
66+
"test/test_http_instrumentation.py::test_cache_hits_recorded",
67+
"test/test_task_manager.py::test_failure_limit_reset",
68+
"test/test_profile.py::test_crash_during_init",
69+
"test/test_simple_commands.py::test_dump_page_source_valid[headless]",
70+
"test/test_profile.py::test_saving",
71+
"test/test_http_instrumentation.py::TestPOSTInstrument::test_record_post_data_x_www_form_urlencoded",
72+
"test/test_js_instrument.py::TestJSInstrumentRecursiveProperties::test_instrument_object",
73+
"test/test_simple_commands.py::test_save_screenshot_valid[xvfb]",
74+
"test/test_http_instrumentation.py::TestPOSTInstrument::test_record_post_data_text_plain",
75+
"test/test_http_instrumentation.py::test_javascript_saving",
76+
"test/test_http_instrumentation.py::test_content_saving",
77+
"test/test_http_instrumentation.py::TestHTTPInstrument::test_worker_script_requests",
78+
"test/test_extension.py::TestExtension::test_js_call_stack",
79+
"test/test_profile.py::test_crash_profile",
80+
"test/test_extension.py::TestExtension::test_canvas_fingerprinting",
81+
"test/test_crawl.py::test_browser_profile_coverage",
82+
"test/test_http_instrumentation.py::TestPOSTInstrument::test_record_file_upload",
83+
"test/test_js_instrument_py.py::test_merge_diff_instrumented_names",
84+
"test/test_js_instrument_py.py::test_merge_multiple_duped_properties",
85+
"test/test_js_instrument_py.py::test_merge_multiple_duped_properties_different_log_settings",
86+
"test/test_js_instrument_py.py::test_api_whole_module",
87+
"test/test_js_instrument_py.py::test_api_two_keys_in_shortcut",
88+
"test/test_js_instrument_py.py::test_api_instances_on_window",
89+
"test/test_js_instrument_py.py::test_api_instances_on_window_with_properties",
90+
"test/test_js_instrument_py.py::test_api_module_specific_properties",
91+
"test/test_js_instrument_py.py::test_api_passing_partial_log_settings"
92+
],
93+
[
94+
"test/test_simple_commands.py::test_recursive_dump_page_source_valid[xvfb]",
95+
"test/extension/test_logging.py::test_extension_logging",
96+
"test/test_timer.py::test_command_duration",
97+
"test/test_http_instrumentation.py::test_page_visit[True]",
98+
"test/storage/test_storage_controller.py::test_arrow_provider",
99+
"test/storage/test_storage_controller.py::test_startup_and_shutdown",
100+
"test/test_mp_logger.py::test_multiple_instances",
101+
"test/test_simple_commands.py::test_recursive_dump_page_source_valid[headless]",
102+
"test/test_http_instrumentation.py::TestPOSTInstrument::test_record_post_data_ajax_no_key_value",
103+
"test/test_http_instrumentation.py::TestPOSTInstrument::test_record_post_formdata",
104+
"test/test_js_instrument.py::TestJSInstrumentExistingWindowProperty::test_instrument_object",
105+
"test/test_simple_commands.py::test_get_http_tables_valid[xvfb]",
106+
"test/test_simple_commands.py::test_dump_page_source_valid[xvfb]",
107+
"test/test_js_instrument.py::TestJSInstrument::test_instrument_object",
108+
"test/test_http_instrumentation.py::TestPOSTInstrument::test_record_post_data_ajax",
109+
"test/test_dns_instrument.py::test_name_resolution",
110+
"test/test_http_instrumentation.py::TestPOSTInstrument::test_record_binary_post_data",
111+
"test/test_http_instrumentation.py::test_document_saving",
112+
"test/test_storage_vectors.py::test_js_profile_cookies",
113+
"test/test_extension.py::TestExtension::test_js_time_stamp",
114+
"test/storage/test_storage_providers.py::test_local_arrow_storage_provider"
115+
],
116+
[
117+
"test/test_js_instrument.py::TestJSInstrumentMockWindowProperty::test_instrument_object",
118+
"test/test_extension.py::TestExtension::test_document_cookie_instrumentation",
119+
"test/test_custom_function_command.py::test_custom_function",
120+
"test/test_http_instrumentation.py::TestPOSTInstrument::test_record_post_data_ajax_no_key_value_base64_encoded",
121+
"test/test_extension.py::TestExtension::test_property_enumeration",
122+
"test/test_simple_commands.py::test_get_site_visits_table_valid[xvfb]",
123+
"test/test_js_instrument.py::TestJSInstrumentNonExistingWindowProperty::test_instrument_object",
124+
"test/test_simple_commands.py::test_get_http_tables_valid[headless]",
125+
"test/test_http_instrumentation.py::TestPOSTInstrument::test_record_post_data_multipart_formdata",
126+
"test/test_http_instrumentation.py::test_page_visit[False]",
127+
"test/test_js_instrument.py::TestJSInstrumentByPython::test_instrument_object",
128+
"test/test_profile.py::test_seed_persistence",
129+
"test/test_callback.py::test_local_callbacks",
130+
"test/test_simple_commands.py::test_get_site_visits_table_valid[headless]",
131+
"test/test_simple_commands.py::test_browse_site_visits_table_valid[headless]",
132+
"test/test_task_manager.py::test_failure_limit_exceeded",
133+
"test/test_extension.py::TestExtension::test_webrtc_localip",
134+
"test/test_simple_commands.py::test_browse_site_visits_table_valid[xvfb]",
135+
"test/storage/test_arrow_cache.py::test_arrow_cache",
136+
"test/test_profile.py::test_save_incomplete_profile_error",
137+
"test/test_webdriver_utils.py::test_parse_neterror_integration",
138+
"test/test_mp_logger.py::test_multiprocess",
139+
"test/test_mp_logger.py::test_child_process_with_exception",
140+
"test/test_mp_logger.py::test_child_process_logging"
141+
]
142+
]

0 commit comments

Comments
 (0)