chromium/third_party/blink/tools/blinkpy/web_tests/web_test_analyzers/fuzzy_diff_analyzer.py

# Copyright 2023 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Script to fuzzy diff analyzer for flaky image comparison web
tests.

Example usage, which finds all failures for image comparison web tests in the
past 3 days. Any tests that both failed and passed more than twice on a
configuration is considered as a flaky test. The script will provide a
recommended fuzzy fixable range for the test:

third_party/blink/tools/run_fuzzy_diff_analyzer.py \
  --sample-period 3

Use 'gcloud auth login' command first for local usage.
Use the --test-path flag to specify the tests you want to perform a fuzzy diff
analysis, instead of all tests.
"""

import argparse
import re
import logging
import urllib.parse

from blinkpy.common.system.log_utils import configure_logging
from blinkpy.w3c.buganizer import BuganizerClient
from blinkpy.web_tests.web_test_analyzers import analyzer
from blinkpy.web_tests.web_test_analyzers import data_types
from blinkpy.web_tests.web_test_analyzers import queries
from blinkpy.web_tests.web_test_analyzers import results


DASHBOARD_BASE_URL = 'go/fuzzy_diff_dashboard'
RESULT_TITLE = 'Fuzzy Diff Analyzer result:'

_log = logging.getLogger(__name__)


def ParseArgs() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=(
        'Script to fuzzy diff analyzer for flaky image comparison web tests'))
    parser.add_argument(
        '--project',
        default='chrome-unexpected-pass-data',
        help=('The billing project to use for BigQuery queries. '
              'Must have access to the ResultDB BQ tables, e.g. '
              '"luci-resultdb.chromium.web_tests_ci_test_results".'))
    parser.add_argument(
        '--image-diff-num-threshold',
        default=3,
        action="store",
        help=
        "Threshold for the number of image diff data, must have this number "
        "to analyze the fuzzy diff range.")
    parser.add_argument(
        '--distinct-diff-num-threshold',
        default=3,
        action="store",
        help="Threshold for the number of distinct image diff data, must this"
        "number to furtuher provide prcentile data.")
    parser.add_argument('--sample-period',
                        type=int,
                        default=1,
                        help='The number of days to sample data from.')
    parser.add_argument(
        '--test-path',
        help='The test path that contains the tests to do fuzzy diff analyzer.'
    )
    parser.add_argument(
        '--check-bugs-only',
        action='store_true',
        default=False,
        help='Only checks the image diff tests result on existing bugs in the'
        ' LUCI analysis database.')
    parser.add_argument(
        '--attach-analysis-result',
        action='store_true',
        default=False,
        help='Attach the fuzzy diff analysis result to the corresponding bug.'
        ' Only used with --check-bugs-only flag.')
    args = parser.parse_args()
    return args


def main() -> int:
    configure_logging(logging_level=logging.INFO, include_time=True)
    args = ParseArgs()

    querier_instance = queries.Querier(args.sample_period, args.project)
    # Find all bug ids or save empty id if it does not check all bugs.
    if args.check_bugs_only:
        bugs_info = querier_instance.get_web_test_flaky_bugs()
        bugs = {}
        for bug in bugs_info:
            test_path_list = [
                re.sub('ninja://:blink_w(eb|pt)_tests/', '', test_id)
                for test_id in bug['test_ids']
            ]
            if bug['bug_id']:
                bugs[bug['bug_id']] = test_path_list
                _log.info('Adding bug to check: %s', bug['bug_id'])
        if args.attach_analysis_result:
            buganizer_api = BuganizerClient()
        _log.info('total bugs: %d', len(bugs))
    else:
        bugs = {'': [args.test_path]}

    results_processor = results.ResultProcessor()
    matching_analyzer = analyzer.FuzzyMatchingAnalyzer(
        args.image_diff_num_threshold, args.distinct_diff_num_threshold)
    bug_ids = []
    for bug_id, test_list in bugs.items():
        bug_result_string = ''
        for test_path in test_list:
            query_results = (querier_instance.
                             get_failed_image_comparison_ci_tests(test_path))
            aggregated_results = results_processor.aggregate_results(
                query_results)
            bug_result_string += analyze_aggregated_results(
                aggregated_results, matching_analyzer, bug_id,
                args.attach_analysis_result)

        # Attach the analysis result for this bug.
        if bug_id and args.attach_analysis_result and bug_result_string:
            bug_result_string = RESULT_TITLE + bug_result_string
            if RESULT_TITLE not in str(
                    buganizer_api.GetIssueComments(int(bug_id))):
                buganizer_api.NewComment(int(bug_id), bug_result_string)
                bug_ids.append(bug_id)
                _log.info('Successfully attach result to bug: %s', bug_id)

    # Insert bug attachment results to database.
    if bug_ids:
        querier_instance.insert_web_test_analyzer_result(
            data_types.FUZZY_DIFF_ANALYZER, data_types.BUGANIZER, bug_ids)

    return 0


def analyze_aggregated_results(
        aggregated_results: data_types.AggregatedSlownessResultsType,
        matching_analyzer: analyzer.FuzzyMatchingAnalyzer, bug_id: str,
        attach_analysis_result: bool) -> str:
    """Analyze the input image test results.

    Args:
      aggregated_results: Image test results.
      matching_analyzer: The analyzer to check fuzzy diff range.
      bug_id: The bug id of test results.
      attach_analysis_result: Attach the result to bug or not.

    Returns:
      A string of the final analysis result.
    """
    res = ''
    for test_name, test_data in aggregated_results.items():
        test_analysis_result = matching_analyzer.run_analyzer(test_data)
        if test_analysis_result.is_analyzed:
            result_string = ''
            if bug_id and not attach_analysis_result:
                result_string = result_string + f'\nBug number: {bug_id}'
            dashboard_link = (DASHBOARD_BASE_URL + '?f=test_name_cgk78f:re:' +
                              urllib.parse.quote(test_name, safe=''))
            result_string = result_string + (
                f'\nTest name: {test_name}'
                f'\nTest Result: {test_analysis_result.analysis_result}'
                f'\nDashboard link: {dashboard_link}\n')
            if not attach_analysis_result:
                print(result_string)
            else:
                res = res + result_string
    return res