blob: 94b752e7af0585c24984684d777d2cdd987d03b3 [file]
#!/usr/bin/env python3
# Copyright 2021 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Test analysis functions for the testing framework.
"""
from collections import defaultdict
import logging
import os
import re
from typing import Dict, List, Set
from models import Action
from models import ActionType
from models import CoverageTest
from models import CoverageTestsByPlatform
from models import CoverageTestsByPlatformSet
from models import TestId
from models import TestIdsTestNamesByPlatformSet
from models import TestPartitionDescription
from models import TestPlatform
def filter_tests_for_partition(tests: List[CoverageTest],
partition: TestPartitionDescription
) -> List[CoverageTest]:
"""Returns tests whose actions match any prefix assigned to `partition`."""
return [
test for test in tests if any(
action.name.startswith(prefix) for action in test.actions
for prefix in partition.action_name_prefixes)
]
def compare_and_print_tests_to_remove_and_add(
existing_tests: TestIdsTestNamesByPlatformSet,
required_tests: CoverageTestsByPlatformSet,
test_partitions: List[TestPartitionDescription],
default_partition: TestPartitionDescription, add_to_file: bool):
"""
Given the existing tests on disk and the required tests, print out the
changes that need to happen to make them match. This also takes into account
test partitioning, so tests are asked to be written to the appropriate test
partition file.
Note: This does NOT support moving tests between partition files. If a test
was found in any partition file, then it is ignored.
"""
def write_tests(filename: str, tests: List[CoverageTest],
partition: TestPartitionDescription,
ordered_tests: List[CoverageTest]):
def update_existing_generated_test_block(existing_block: str,
new_block: str) -> str:
"""Replace helper_ body; preserve existing header when possible."""
existing_body_match = re.search(r'(?m)^[ \t]*helper_\.',
existing_block)
new_body_match = re.search(r'(?m)^[ \t]*helper_\.', new_block)
existing_body_start = (existing_body_match.start()
if existing_body_match else -1)
new_body_start = (new_body_match.start() if new_body_match else -1)
existing_body_end = existing_block.rfind("\n}")
new_body_end = new_block.rfind("\n}")
if min(existing_body_start, new_body_start, existing_body_end,
new_body_end) == -1:
return new_block
return (existing_block[:existing_body_start] +
new_block[new_body_start:new_body_end] +
existing_block[existing_body_end:])
def shared_name_segments(test_name_a: str, test_name_b: str) -> int:
shared_segments = 0
for segment_a, segment_b in zip(test_name_a.split("_"),
test_name_b.split("_")):
if segment_a != segment_b:
break
shared_segments += 1
return shared_segments
def find_generated_matches(test_file: str) -> List[re.Match[str]]:
"""Return regex matches for generated test blocks appearing
after the '// Generated tests:' marker, or in the whole file
if the marker is absent."""
generated_tests_start = test_file.find("// Generated tests:")
if generated_tests_start == -1:
generated_tests_start = 0
return [
match for match in re.finditer(
'IN_PROC_BROWSER_TEST_[PF][\\(\\w\\s,]+'
f'{CoverageTest.TEST_ID_PREFIX}([a-zA-Z0-9._-]+)\\)'
'\\s*{\\n(?:\\s*\\/\\/.*\\n)+((?:[^;^}}]+;\\n)+)}',
test_file) if match.start() > generated_tests_start
]
new_test_str = "\n\n".join(
test.generate_browsertest(partition) for test in tests)
if add_to_file:
if os.path.exists(filename):
with open(filename, "r", encoding="utf-8") as f:
test_file = f.read()
generated_matches = find_generated_matches(test_file)
existing_matches_by_name = {
match.group(1): match
for match in generated_matches
}
tests_by_name = {
test.generate_test_name(): test
for test in tests
}
if generated_matches:
# Phase 1: Update tests that already exist by name.
# Process in reverse file order so earlier match positions
# remain valid after each replacement.
replacement_names = [
test_name for test_name in tests_by_name
if test_name in existing_matches_by_name
]
for test_name in sorted(
replacement_names,
key=lambda name: existing_matches_by_name[
name].start(),
reverse=True):
match = existing_matches_by_name[test_name]
new_block = tests_by_name[
test_name].generate_browsertest(partition)
updated_block = update_existing_generated_test_block(
match.group(0), new_block)
test_file = (test_file[:match.start()] +
updated_block + test_file[match.end():])
if replacement_names:
generated_matches = find_generated_matches(test_file)
existing_matches_by_name = {
match.group(1): match
for match in generated_matches
}
# Phase 2: Insert new tests near the most similar existing
# test by name prefix.
insertion_names = [
test.generate_test_name() for test in ordered_tests
if test.generate_test_name() in tests_by_name
and test.generate_test_name() not in replacement_names
]
for test_name in insertion_names:
insertion_position = (generated_matches[-1].end()
if generated_matches else
len(test_file))
max_shared_segments = 0
most_similar_name = None
for existing_name in existing_matches_by_name:
shared_segments = shared_name_segments(
existing_name, test_name)
if (shared_segments > max_shared_segments
or (shared_segments == max_shared_segments
and shared_segments > 0)):
max_shared_segments = shared_segments
most_similar_name = existing_name
if max_shared_segments >= 1:
insertion_position = existing_matches_by_name[
most_similar_name].end()
insertion_block = tests_by_name[
test_name].generate_browsertest(partition)
# Add spacing: prepend newlines when inserting after
# a test block; otherwise append newlines after insertion.
inserts_after_existing_test = (
(generated_matches and insertion_position
== generated_matches[-1].end()) or
(most_similar_name is not None
and insertion_position ==
existing_matches_by_name[most_similar_name].end())
)
if inserts_after_existing_test:
insertion_str = f"\n\n{insertion_block}"
else:
insertion_str = f"{insertion_block}\n\n"
test_file = (test_file[:insertion_position] +
insertion_str +
test_file[insertion_position:])
generated_matches = find_generated_matches(test_file)
existing_matches_by_name = {
match.group(1): match
for match in generated_matches
}
# Fallback to adding the tests to the end of the file instead
# of the smarter approach.
if not generated_matches:
# Find the last test in the test file
matches = list(
re.finditer(r"IN_PROC_BROWSER_TEST_[PF](.|\n)*?}\n",
test_file))
if matches:
last_test_end_index = matches[-1].end()
else:
# If no tests found, try to insert before the last closing brace
# (which is usually the closing namespace).
last_brace_index = test_file.rfind("}")
if last_brace_index != -1:
# Find the start of the line with the last brace to be clean.
last_test_end_index = test_file.rindex(
'\n', 0, last_brace_index) + 1
else:
last_test_end_index = len(test_file)
test_file = (test_file[:last_test_end_index] + "\n" +
new_test_str + "\n" +
test_file[last_test_end_index:])
with open(filename, "w", encoding="utf-8") as f:
f.write(test_file)
else:
print(f"\n\nCreate a new test file: {filename}\n"
"Remember to add the new test file to the BUILD file.\n"
"Add the following tests to the new test file:\n"
f"\n{new_test_str}\n")
else:
print(f"\n\nAdd the following tests to {filename}:\n"
f"\n{new_test_str}\n")
test_ids_to_keep: Dict[frozenset[TestPlatform],
Set[TestId]] = defaultdict(set)
test_names_to_keep: Dict[frozenset[TestPlatform],
Set[str]] = defaultdict(set)
for platforms, tests in required_tests.items():
tests_to_add: List[CoverageTest] = []
for test in tests:
test_names_to_keep[platforms].add(test.generate_test_name())
if platforms in existing_tests:
existing_test_set = {
test_id
for (test_id, _) in existing_tests[platforms]
}
if test.id not in existing_test_set:
tests_to_add.append(test)
else:
test_ids_to_keep[platforms].add(test.id)
else:
tests_to_add.append(test)
tests_added_to_partition: Set[TestId] = set()
for partition in test_partitions:
tests_to_add_partition = filter_tests_for_partition(
tests_to_add, partition)
if not tests_to_add_partition:
continue
# Record all tests to ensure we don't have duplicates in different
# files, and to output remaining tests to the default partition.
for test in tests_to_add_partition:
if test.id in tests_added_to_partition:
raise ValueError(
"Cannot have a test written to multiple test files.")
tests_added_to_partition.add(test.id)
filename = partition.generate_browsertest_filepath(platforms)
ordered_partition_tests = filter_tests_for_partition(
tests, partition)
write_tests(filename, tests_to_add_partition, partition,
ordered_partition_tests)
# All remaining tests go into the default partition
default_tests: List[CoverageTest] = [
test for test in tests_to_add
if test.id not in tests_added_to_partition
]
if not default_tests:
continue
filename = default_partition.generate_browsertest_filepath(platforms)
ordered_default_tests = [
test for test in tests if test.id not in tests_added_to_partition
]
write_tests(filename, default_tests, default_partition,
ordered_default_tests)
# Print out all tests to remove. To keep the algorithm simple the partition
# is not kept track of.
for platforms, test_ids_names in existing_tests.items():
tests_to_remove = []
prompt_str = ""
nice_platform_str = ", ".join(
[f"{platform}" for platform in platforms])
if platforms not in test_ids_to_keep:
prompt_str = (f"\n\nRemove ALL tests from the file for the "
f"platforms [{nice_platform_str}]:\n")
tests_to_remove = [
test_name for (_, test_name) in test_ids_names
if test_name not in test_names_to_keep[platforms]
]
else:
prompt_str = (f"\n\nRemove these tests from the file for the "
f"platforms [{nice_platform_str}]:\n")
tests_to_remove = [
test_name for (test_id, test_name) in test_ids_names
if test_id not in test_ids_to_keep[platforms]
and test_name not in test_names_to_keep[platforms]
]
if not tests_to_remove:
continue
print(f"{prompt_str}{', '.join(tests_to_remove)}")
def expand_parameterized_tests(coverage_tests: List[CoverageTest]
) -> List[CoverageTest]:
"""
Takes a list of coverage tests that contain parameterized actions, and
expands all of the tests with those actions to result in a list of tests
without parameterized actions.
"""
def get_all_parameterized_tests(test_actions: List[Action]
) -> List[List[Action]]:
"""
Takes a list of actions with possible parameterized actions, and outputs
a list of resulting tests with all parameterized actions expanded.
"""
if not test_actions:
return [[]]
for i, action in enumerate(test_actions):
if action.type is not ActionType.PARAMETERIZED:
continue
actions_before_parameterized = test_actions[:i]
actions_after_parameterized = test_actions[i + 1:]
resulting_tests = []
for output_action in action.output_actions:
remaining_expanded_tests = get_all_parameterized_tests(
actions_after_parameterized)
for remaining_test in remaining_expanded_tests:
test = (actions_before_parameterized + [output_action] +
remaining_test)
resulting_tests.append(test)
return resulting_tests
# No parameterized actions were found, so just return the test actions.
return [test_actions]
result_tests = []
for test in coverage_tests:
queue = [test.actions]
final_expanded_tests = []
while queue:
current_actions = queue.pop(0)
expanded = get_all_parameterized_tests(current_actions)
for expanded_test in expanded:
if any(action.type is ActionType.PARAMETERIZED
for action in expanded_test):
queue.append(expanded_test)
else:
final_expanded_tests.append(expanded_test)
logging.info(
f"Generated {len(final_expanded_tests)} test/s from {test.id}")
for resulting_test in final_expanded_tests:
result_tests.append(CoverageTest(resulting_test, test.platforms))
return result_tests
def filter_coverage_tests_for_platform(tests: List[CoverageTest],
platform: TestPlatform
) -> List[CoverageTest]:
return [test for test in tests if platform in test.platforms]
def partition_framework_tests_per_platform_combination(
generated_tests_per_platform: CoverageTestsByPlatform
) -> CoverageTestsByPlatformSet:
test_id_to_platforms = defaultdict(lambda: set())
test_id_to_test = {}
platform_set_to_tests = defaultdict(lambda: list())
for platform, tests in generated_tests_per_platform.items():
for test in tests:
test_id_to_platforms[test.id].add(platform)
if test.id not in test_id_to_test:
test_id_to_test[test.id] = CoverageTest(test.actions, set())
test_id_to_test[test.id].platforms.add(platform)
for test_id, platforms in test_id_to_platforms.items():
platforms = frozenset(platforms)
platform_set_to_tests[platforms].append(test_id_to_test[test_id])
return platform_set_to_tests