| # Copyright 2020 The Chromium Authors. All rights reserved. | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| import html | |
| import re | |
| from recipe_engine import recipe_api | |
| # The maximum number of characters to be included in the summary markdown. | |
| # Even though the max size for the markdown is 4000 bytes we are saving 500 | |
| # bytes for addittional prefixes added automatically by LUCI like the number | |
| # of failed steps out of the total. | |
| MAX_CHARS = 3500 | |
| # Default timeout for tests seconds | |
| TIMEOUT_SECS = 3600 | |
| # Map between iphone identifier and generation name. | |
| IDENTIFIER_NAME_MAP = { | |
| 'iPhone1,1': 'iPhone', | |
| 'iPhone1,2': 'iPhone 3G', | |
| 'iPhone2,1': 'iPhone 3GS', | |
| 'iPhone3,1': 'iPhone 4', | |
| 'iPhone3,2': 'iPhone 4', | |
| 'iPhone3,3': 'iPhone 4', | |
| 'iPhone4,1': 'iPhone 4S', | |
| 'iPhone5,1': 'iPhone 5', | |
| 'iPhone5,2': 'iPhone 5', | |
| 'iPhone5,3': 'iPhone 5c', | |
| 'iPhone5,4': 'iPhone 5c', | |
| 'iPhone6,1': 'iPhone 5s', | |
| 'iPhone6,2': 'iPhone 5s', | |
| 'iPhone7,2': 'iPhone 6', | |
| 'iPhone7,1': 'iPhone 6 Plus', | |
| 'iPhone8,1': 'iPhone 6s', | |
| 'iPhone8,2': 'iPhone 6s Plus', | |
| 'iPhone8,4': 'iPhone SE (1st generation)', | |
| 'iPhone9,1': 'iPhone 7', | |
| 'iPhone9,3': 'iPhone 7', | |
| 'iPhone9,2': 'iPhone 7 Plus', | |
| 'iPhone9,4': 'iPhone 7 Plus', | |
| 'iPhone10,1': 'iPhone 8', | |
| 'iPhone10,4': 'iPhone 8', | |
| 'iPhone10,2': 'iPhone 8 Plus', | |
| 'iPhone10,5': 'iPhone 8 Plus', | |
| 'iPhone10,3': 'iPhone X', | |
| 'iPhone10,6': 'iPhone X', | |
| 'iPhone11,8': 'iPhone XR', | |
| 'iPhone11,2': 'iPhone XS', | |
| 'iPhone11,6': 'iPhone XS Max', | |
| 'iPhone12,1': 'iPhone 11', | |
| 'iPhone12,3': 'iPhone 11 Pro', | |
| 'iPhone12,5': 'iPhone 11 Pro Max', | |
| 'iPhone12,8': 'iPhone SE (2nd generation)', | |
| 'iPhone13,1': 'iPhone 12 mini', | |
| 'iPhone13,2': 'iPhone 12', | |
| 'iPhone13,3': 'iPhone 12 Pro', | |
| 'iPhone13,4': 'iPhone 12 Pro Max', | |
| 'iPhone14,4': 'iPhone 13 mini', | |
| 'iPhone14,5': 'iPhone 13', | |
| 'iPhone14,2': 'iPhone 13 Pro', | |
| 'iPhone14,3': 'iPhone 13 Pro Max', | |
| 'iPhone17,3': 'iPhone 16 Pro', | |
| } | |
| # Regexp for windows os version number | |
| _WINDOWS_OS_RE = r'\[version (\d+\.\d+)\.(\d+(?:\.\d+|))\]' | |
| # Same as StepFailure except that the message is assumed to be | |
| # already formatted. (StepFailure assumes that the message needs | |
| # to be further escaped.) | |
| # See https://chromium.googlesource.com/infra/luci/recipes-py/+/HEAD/recipe_engine/recipe_api.py#399 | |
| class PrettyFailure(recipe_api.StepFailure): | |
| """Wrapper class to avoid the ! formatter in StepFailure""" | |
| def reason_message(self): | |
| return '{}\nStep failed'.format(self.name) | |
| class TestUtilsApi(recipe_api.RecipeApi): | |
| """Utilities to run flutter tests.""" | |
| def _truncateString(self, string): | |
| """Truncate the string by lines from the end so that the output has | |
| less than MAX_CHARS bytes.""" | |
| byte_count = 0 | |
| lines = string.splitlines() | |
| output = [] | |
| for line in reversed(lines): | |
| # +1 to account for the \n separators. | |
| byte_count += len(line.encode('utf-8')) + 1 | |
| if byte_count >= MAX_CHARS: | |
| break | |
| output.insert(0, line) | |
| return '\n'.join(output) | |
| def _is_flaky(self, output): | |
| """Check if test step is flaky""" | |
| lines = output.splitlines() | |
| lines.reverse() | |
| # The flakiness status message `flaky: true` is expected to be located at the | |
| # end of the stdout file. Check last 10 lines to make sure it is covered if existing. | |
| for line in lines[:10]: | |
| if 'flaky: true' in line: | |
| return True | |
| return False | |
| #def is_devicelab_bot(self): | |
| # """Whether the current bot is a devicelab bot or not.""" | |
| # return ( | |
| # str(self.m.swarming.bot_id).startswith('flutter-devicelab') or | |
| # str(self.m.swarming.bot_id).startswith('flutter-win') | |
| # ) | |
| def run_test( | |
| self, | |
| step_name, | |
| command_list, | |
| timeout_secs=TIMEOUT_SECS, | |
| infra_step=False, | |
| ): | |
| """Recipe's step wrapper to collect stdout and add it to step_summary. | |
| Args: | |
| step_name(str): The name of the step. | |
| command_list(list(str)): A list of strings with the command and | |
| parameters to execute. | |
| timeout_secs(int): The timeout in seconds for this step. | |
| infra_step: mark step as an infra step | |
| Returns(str): The status of the test step. A str `flaky` or `success` will | |
| be returned when step succeeds, and an exception will be thrown out when | |
| step fails. | |
| """ | |
| if self.m.platform.is_win: | |
| resource_name = self.resource('runner.ps1') | |
| shell = 'powershell' | |
| else: | |
| resource_name = self.resource('runner.sh') | |
| shell = 'bash' | |
| logs_file = self.m.path.mkstemp() | |
| env = {'LOGS_FILE': logs_file} | |
| with self.m.context(env=env): | |
| try: | |
| final_cmd = [shell, resource_name] | |
| final_cmd.extend(command_list) | |
| self.m.step( | |
| step_name, | |
| final_cmd, | |
| infra_step=infra_step, | |
| timeout=timeout_secs, | |
| ) | |
| # Read logs to analyze flakiness. | |
| logs = self.m.file.read_text('read_logs', logs_file) | |
| step_stdout = self.m.properties.get( | |
| 'fake_data' | |
| ) if self._test_data.enabled else logs | |
| if self._is_flaky(step_stdout): | |
| test_run_status = 'flaky' | |
| self.flaky_step(step_name, logs) | |
| else: | |
| test_run_status = 'success' | |
| except self.m.step.StepFailure as f: | |
| result = f.result | |
| logs = self.m.file.read_text('read_logs', logs_file) | |
| # Truncate stdout | |
| result_stdout = self.m.properties.get( | |
| 'fake_data' | |
| ) if self._test_data.enabled else logs | |
| truncated_stdout = self._truncateString(result_stdout) | |
| raise PrettyFailure( | |
| '\n\n```\n%s\n```\n' % (truncated_stdout), | |
| result=result, | |
| ) | |
| return test_run_status | |
| def test_step_name(self, step_name): | |
| """Append keyword test to test step name to be consistent. | |
| Args: | |
| step_name(str): The name of the step. | |
| Returns(str): The test step name prefixed with "test". | |
| """ | |
| return 'test: %s' % step_name | |
| def flaky_step(self, step_name, stdout=''): | |
| """Add a flaky step when test is flaky. | |
| Args: | |
| step_name(str): The name of the step. | |
| """ | |
| with self.m.step.nest('step is flaky: %s' % step_name) as presentation: | |
| presentation.logs["stdout"] = stdout | |
| presentation.status = self.m.step.FAILURE | |
| def collect_benchmark_tags(self, env, env_prefixes, target_tags): | |
| """Collect host and device tags for devicelab benchmarks. | |
| Args: | |
| env(dict): Current environment variables. | |
| env_prefixes(dict): Current environment prefixes variables. | |
| target_tags(list(str)): Builder tags defined in .ci.yaml targets. | |
| Returns: | |
| A dictionary representation of the tag names and values. | |
| Examples: | |
| Linux/android: | |
| { | |
| 'arch': 'intel', | |
| 'host_type': 'linux', | |
| 'device_type': 'Moto G Play' | |
| } | |
| Mac/ios: | |
| { | |
| 'arch': 'm1', | |
| 'host_type': 'mac', | |
| 'device_type': 'iPhone 6s' | |
| } | |
| Windows/android: | |
| { | |
| 'arch': 'intel', | |
| 'host_type': 'win', | |
| 'device_type': 'Moto G Play' | |
| } | |
| """ | |
| device_tags = {} | |
| def _get_tag(step_name, commands): | |
| return self.m.step( | |
| step_name, | |
| commands, | |
| stdout=self.m.raw_io.output_text(), | |
| infra_step=True, | |
| ).stdout.rstrip() | |
| # Collect device tags. | |
| # | |
| # Mac/iOS testbeds always have builder_name starting with `Mac_ios`. | |
| # The android tests always have builder_name like `%_android %`. | |
| # | |
| # We may need to support other platforms like desktop, and | |
| # https://github.com/flutter/flutter/issues/92296 to track a more | |
| # generic way to collect device tags. | |
| if 'ios' in target_tags: | |
| with self.m.context(env=env, env_prefixes=env_prefixes): | |
| iphone_identifier = _get_tag( | |
| 'Find device type', ['ideviceinfo', '--key', 'ProductType'] | |
| ) | |
| device_tags['device_type'] = IDENTIFIER_NAME_MAP[iphone_identifier] | |
| elif 'android' in target_tags: | |
| with self.m.context(env=env, env_prefixes=env_prefixes): | |
| device_tags['device_type'] = _get_tag( | |
| 'Find device type', ['adb', 'shell', 'getprop', 'ro.product.model'] | |
| ) | |
| else: | |
| device_tags['device_type'] = 'none' | |
| # Use same `none` as device version to consolidate metric data streams. | |
| # Do not use real distinct values: https://github.com/flutter/flutter/issues/112804 | |
| device_tags['device_version'] = 'none' | |
| device_tags['host_type'] = self.m.platform.name | |
| device_tags['arch'] = self.m.properties.get('arch', self.m.platform.arch) | |
| return device_tags |