1#!/usr/bin/env python3
2#
3#   Copyright 2021 - The Android Open Source Project
4#
5#   Licensed under the Apache License, Version 2.0 (the "License");
6#   you may not use this file except in compliance with the License.
7#   You may obtain a copy of the License at
8#
9#       http://www.apache.org/licenses/LICENSE-2.0
10#
11#   Unless required by applicable law or agreed to in writing, software
12#   distributed under the License is distributed on an "AS IS" BASIS,
13#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14#   See the License for the specific language governing permissions and
15#   limitations under the License.
16
17"""Incremental dEQP
18
19This script will run a subset of dEQP test on device to get dEQP dependency.
20
21Usage 1: Compare with a base build to check if any dEQP dependency has
22changed. Output a decision if dEQP could be skipped, and a cts-tradefed
23command to be used based on the decision.
24
25python3 incremental_deqp.py -s [device serial] -t [test directory] -b
26[base build target file] -c [current build target file]
27
28Usage 2: Generate a file containing a list of dEQP dependencies for the
29build on device.
30
31python3 incremental_deqp.py -s [device serial] -t [test directory]
32--generate_deps_only
33
34"""
35import argparse
36import importlib
37import logging
38import os
39import pkgutil
40import re
41import subprocess
42import tempfile
43import time
44import uuid
45from target_file_handler import TargetFileHandler
46from custom_build_file_handler import CustomBuildFileHandler
47from zipfile import ZipFile
48
49
50DEFAULT_CTS_XML = ('<?xml version="1.0" encoding="utf-8"?>\n'
51                   '<configuration description="Runs CTS from a pre-existing CTS installation">\n'
52                   '   <include name="cts-common" />\n'
53                   '   <include name="cts-exclude" />\n'
54                   '   <include name="cts-exclude-instant" />\n'
55                   '   <option name="enable-token-sharding" '
56                   'value="true" />\n'
57                   '   <option name="plan" value="cts" />\n'
58                   '</configuration>\n')
59
60INCREMENTAL_DEQP_XML = ('<?xml version="1.0" encoding="utf-8"?>\n'
61                        '<configuration description="Runs CTS with incremental dEQP">\n'
62                        '   <include name="cts-common" />\n'
63                        '   <include name="cts-exclude" />\n'
64                        '   <include name="cts-exclude-instant" />\n'
65                        '   <option name="enable-token-sharding" '
66                        'value="true" />\n'
67                        '   <option name="compatibility:exclude-filter" '
68                        'value="CtsDeqpTestCases" />\n'
69                        '   <option name="plan" value="cts" />\n'
70                        '</configuration>\n')
71
72logger = logging.getLogger()
73
74
75class AtsError(Exception):
76  """Error when running incremental dEQP with Android Test Station"""
77  pass
78
79class AdbError(Exception):
80  """Error when running adb command."""
81  pass
82
83class TestError(Exception):
84  """Error when running dEQP test."""
85  pass
86
87class TestResourceError(Exception):
88  """Error with test resource. """
89  pass
90
91class BuildHelper(object):
92  """Helper class for analyzing build."""
93
94  def __init__(self, custom_handler=False):
95    """Init BuildHelper.
96
97    Args:
98      custom_handler: use custom build handler.
99    """
100    self._build_file_handler = TargetFileHandler
101    if custom_handler:
102      self._build_file_handler = CustomBuildFileHandler
103
104
105  def compare_base_build_with_current_build(self, deqp_deps, current_build_file,
106                                            base_build_file):
107    """Compare the difference of current build and base build with dEQP dependency.
108
109    If the difference doesn't involve dEQP dependency, current build could skip dEQP test if
110    base build has passed test.
111
112    Args:
113      deqp_deps: a set of dEQP dependency.
114      current_build_file: current build's file name.
115      base_build_file: base build's file name.
116    Returns:
117      True if current build could skip dEQP, otherwise False.
118    """
119    print('Comparing base build and current build...')
120    current_build_handler = self._build_file_handler(current_build_file)
121    current_build_hash = current_build_handler.get_file_hash(deqp_deps)
122
123    base_build_handler = self._build_file_handler(base_build_file)
124    base_build_hash = base_build_handler.get_file_hash(deqp_deps)
125
126    return self._compare_build_hash(current_build_hash, base_build_hash)
127
128
129  def compare_base_build_with_device_files(self, deqp_deps, adb, base_build_file):
130    """Compare the difference of files on device and base build with dEQP dependency.
131
132    If the difference doesn't involve dEQP dependency, current build could skip dEQP test if
133    base build has passed test.
134
135    Args:
136      deqp_deps: a set of dEQP dependency.
137      adb: an instance of AdbHelper for current device under test.
138      base_build_file: base build file name.
139    Returns:
140      True if current build could skip dEQP, otherwise False.
141    """
142    print('Comparing base build and current build on the device...')
143    # Get current build's hash.
144    current_build_hash = dict()
145    for dep in deqp_deps:
146      content = adb.run_shell_command('cat ' + dep)
147      current_build_hash[dep] = hash(content)
148
149    base_build_handler = self._build_file_handler(base_build_file)
150    base_build_hash = base_build_handler.get_file_hash(deqp_deps)
151
152    return self._compare_build_hash(current_build_hash, base_build_hash)
153
154
155  def _compare_build_hash(self, current_build_hash, base_build_hash):
156    """Compare the hash value of current build and base build.
157
158    Args:
159      current_build_hash: map of current build where key is file name, and value is content hash.
160      base_build_hash: map of base build where key is file name and value is content hash.
161    Returns:
162      boolean about if two builds' hash is the same.
163    """
164    if current_build_hash == base_build_hash:
165      print('Done!')
166      return True
167
168    for key, val in current_build_hash.items():
169      if key not in base_build_hash:
170        logger.info('File:{build_file} was not found in base build'.format(build_file=key))
171      elif base_build_hash[key] != val:
172        logger.info('Detected dEQP dependency file difference:{deps}. Base build hash:{base}, '
173                    'current build hash:{current}'.format(deps=key, base=base_build_hash[key],
174                                                          current=val))
175    print('Done!')
176    return False
177
178
179class AdbHelper(object):
180  """Helper class for running adb."""
181
182  def __init__(self, device_serial=None):
183    """Initialize AdbHelper.
184
185    Args:
186      device_serial: A string of device serial number, optional.
187    """
188    self._device_serial = device_serial
189
190  def _run_adb_command(self, *args):
191    """Run adb command."""
192    adb_cmd = ['adb']
193    if self._device_serial:
194      adb_cmd.extend(['-s', self._device_serial])
195    adb_cmd.extend(args)
196    adb_cmd = ' '.join(adb_cmd)
197    completed = subprocess.run(adb_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
198    if completed.returncode != 0:
199      raise AdbError('adb command: {cmd} failed with error: {error}'
200                     .format(cmd=adb_cmd, error=completed.stderr))
201
202    return completed.stdout
203
204  def push_file(self, source_file, destination_file):
205    """Push a file from device to host.
206
207    Args:
208      source_file: A string representing file to push.
209      destination_file: A string representing target on device to push to.
210    """
211    return self._run_adb_command('push', source_file, destination_file)
212
213  def pull_file(self, source_file, destination_file):
214    """Pull a file to device.
215
216    Args:
217      source_file: A string representing file on device to pull.
218      destination_file: A string representing target on host to pull to.
219    """
220    return self._run_adb_command('pull', source_file, destination_file)
221
222  def run_shell_command(self, command):
223    """Run a adb shell command.
224
225    Args:
226      command: A string of command to run, executed through 'adb shell'
227    """
228    return self._run_adb_command('shell', command)
229
230class DeqpDependencyCollector(object):
231  """Collect dEQP dependency from device under test."""
232
233  def __init__(self, work_dir, test_dir, adb):
234    """Init DeqpDependencyCollector.
235
236    Args:
237      work_dir: path of directory for saving script result and logs.
238      test_dir: path of directory for incremental dEQP test file.
239      adb: an instance of AdbHelper.
240    """
241    self._work_dir = work_dir
242    self._test_dir = test_dir
243    self._adb = adb
244    # dEQP dependency with pattern below are not an actual file:
245    # files has prefix /data/ are not system files, e.g. intermediate files.
246    # [vdso] is virtual dynamic shared object.
247    # /dmabuf is a temporary file.
248    self._exclude_deqp_pattern = re.compile('(^/data/|^\[vdso\]|^/dmabuf)')
249
250  def check_test_log(self, test_file, log_file):
251    """Check test's log to see if all tests are executed.
252
253    Args:
254      test_file: Name of test .txt file.
255      log_content: Name of log file.
256    Returns:
257      True if all tests are executed, otherwise False.
258    """
259    test_cnt = 0
260    with open(test_file, 'r') as f:
261      for _ in f:
262        test_cnt += 1
263
264    executed_test_cnt = 0
265
266    with open(log_file, 'r') as f:
267      for line in f:
268        # 'NotSupported' status means test is not supported in device.
269        # TODO(yichunli): Check with graphics team if failed test is allowed.
270        if ('StatusCode="Pass"' in line or 'StatusCode="NotSupported"' in line or
271            'StatusCode="Fail"' in line):
272          executed_test_cnt += 1
273    return executed_test_cnt == test_cnt
274
275  def update_dependency(self, deps, dump):
276    """Parse perf dump file and update dEQP dependency.
277
278     Below is an example of how dump file looks like:
279     630 record comm: type 3, misc 0, size 64
280     631   pid 23365, tid 23365, comm simpleperf
281     632   sample_id: pid 0, tid 0
282     633   sample_id: time 0
283     634   sample_id: id 23804
284     635   sample_id: cpu 0, res 0
285  .......
286     684 record comm: type 3, misc 8192, size 64
287     685   pid 23365, tid 23365, comm deqp-binary64
288     686   sample_id: pid 23365, tid 23365
289     687   sample_id: time 595063921159958
290     688   sample_id: id 23808
291     689   sample_id: cpu 4, res 0
292  .......
293     698 record mmap2: type 10, misc 8194, size 136
294     699   pid 23365, tid 23365, addr 0x58b817b000, len 0x3228000
295     700   pgoff 0x0, maj 253, min 9, ino 14709, ino_generation 2575019956
296     701   prot 1, flags 6146, filename /data/local/tmp/deqp-binary64
297     702   sample_id: pid 23365, tid 23365
298     703   sample_id: time 595063921188552
299     704   sample_id: id 23808
300     705   sample_id: cpu 4, res 0
301
302    Args:
303      deps: a set of string containing dEQP dependency.
304      dump: perf dump file's name.
305    """
306    binary_executed = False
307    correct_mmap = False
308    with open(dump, 'r') as f:
309      for line in f:
310        # It means dEQP binary starts to be executed.
311        if re.search(' comm .*deqp-binary', line):
312          binary_executed = True
313        if not binary_executed:
314          continue
315        # We get a new perf event
316        if not line.startswith(' '):
317          # mmap with misc 1 is not for deqp binary.
318          correct_mmap = line.startswith('record mmap') and 'misc 1,' not in line
319        # Get file name in memory map.
320        if 'filename' in line and correct_mmap:
321          deps_file = line[line.find('filename') + 9:].strip()
322          if not re.search(self._exclude_deqp_pattern, deps_file):
323            deps.add(deps_file)
324
325
326  def get_test_binary_name(self, test_name):
327    """Get dEQP binary's name based on test name.
328
329    Args:
330      test_name: name of test.
331    Returns:
332      dEQP binary's name.
333    """
334    if test_name.endswith('32'):
335      return 'deqp-binary'
336    elif test_name.endswith('64'):
337      return 'deqp-binary64'
338    else:
339      raise TestError('Fail to get dEQP binary due to unknonw test name: ' + test_name)
340
341  def get_test_log_name(self, test_name):
342    """Get test log's name based on test name.
343
344    Args:
345      test_name: name of test.
346    Returns:
347      test log's name when running dEQP test.
348    """
349    return test_name + '.qpa'
350
351  def get_test_perf_name(self, test_name):
352    """Get perf file's name based on test name.
353
354    Args:
355      test_name: name of test.
356    Returns:
357      perf file's name.
358    """
359    return test_name + '.data'
360
361  def get_perf_dump_name(self, test_name):
362    """Get perf dump file's name based on test name.
363
364    Args:
365      test_name: name of test.
366    Returns:
367      perf dump file's name.
368    """
369    return test_name + '-perf-dump.txt'
370
371  def get_test_list_name(self, test_name):
372    """Get test list file's name based on test name.
373
374    test list file is used to run dEQP test.
375
376    Args:
377      test_name: name of test.
378    Returns:
379      test list file's name.
380    """
381    if test_name.startswith('vk'):
382      return 'vk-master-subset.txt'
383    elif test_name.startswith('gles3'):
384      return 'gles3-master-subset.txt'
385    else:
386      raise TestError('Fail to get test list due to unknown test name: ' + test_name)
387
388  def get_deqp_dependency(self):
389    """Get dEQP dependency.
390
391    Returns:
392      A set of dEQP dependency.
393    """
394    device_deqp_dir = '/data/local/tmp'
395    device_deqp_out_dir = '/data/local/tmp/out'
396    test_list = ['vk-32', 'vk-64', 'gles3-32', 'gles3-64']
397
398    # Clean up the device.
399    self._adb.run_shell_command('rm -rRf ' + device_deqp_dir + '/*')
400    self._adb.run_shell_command('mkdir -p ' + device_deqp_out_dir)
401
402    # Copy test resources to device.
403    logger.info(self._adb.push_file(self._test_dir + '/*', device_deqp_dir))
404
405    # Run the dEQP binary with simpleperf
406    print('Running a subset of dEQP tests as binary on the device...')
407    deqp_deps = set()
408    for test in test_list:
409      test_file = os.path.join(device_deqp_dir, self.get_test_list_name(test))
410      log_file = os.path.join(device_deqp_out_dir, self.get_test_log_name(test))
411      perf_file = os.path.join(device_deqp_out_dir, self.get_test_perf_name(test))
412      deqp_binary = os.path.join(device_deqp_dir, self.get_test_binary_name(test))
413      simpleperf_command = ('"cd {device_deqp_dir} && simpleperf record -o {perf_file} {binary} '
414                            '--deqp-caselist-file={test_list} --deqp-log-images=disable '
415                            '--deqp-log-shader-sources=disable --deqp-log-filename={log_file} '
416                            '--deqp-surface-type=fbo --deqp-surface-width=2048 '
417                            '--deqp-surface-height=2048"')
418      self._adb.run_shell_command(
419          simpleperf_command.format(device_deqp_dir=device_deqp_dir, binary=deqp_binary,
420                                    perf_file=perf_file, test_list=test_file, log_file=log_file))
421
422      # Check test log.
423      host_log_file = os.path.join(self._work_dir, self.get_test_log_name(test))
424      self._adb.pull_file(log_file, host_log_file )
425      if not self.check_test_log(os.path.join(self._test_dir, self.get_test_list_name(test)),
426                                 host_log_file):
427        error_msg = ('Fail to run incremental dEQP because of crashed test. Check test'
428                     'log {} for more detail.').format(host_log_file)
429        logger.error(error_msg)
430        raise TestError(error_msg)
431    print('Tests are all passed!')
432
433    # Parse perf dump result to get dependency.
434    print('Analyzing dEQP dependency...')
435    for test in test_list:
436      perf_file = os.path.join(device_deqp_out_dir, self.get_test_perf_name(test))
437      dump_file = os.path.join(self._work_dir, self.get_perf_dump_name(test))
438      self._adb.run_shell_command('simpleperf dump {perf_file} > {dump_file}'
439                                  .format(perf_file=perf_file, dump_file=dump_file))
440      self.update_dependency(deqp_deps, dump_file)
441    print('Done!')
442    return deqp_deps
443
444def _is_deqp_dependency(dependency_name):
445  """Check if dependency is related to dEQP."""
446  # dEQP dependency with pattern below will not be used to compare build:
447  # files has /apex/ prefix are not related to dEQP.
448  return not re.search(re.compile('^/apex/'), dependency_name)
449
450def _get_parser():
451  parser = argparse.ArgumentParser(description='Run incremental dEQP on devices.')
452  parser.add_argument('-s', '--serial', help='Optional. Use device with given serial.')
453  parser.add_argument('-t', '--test', help=('Optional. Directory of incremental deqp test file. '
454                                            'This directory should have test resources and dEQP '
455                                            'binaries.'))
456  parser.add_argument('-b', '--base_build', help=('Target file of base build that has passed dEQP '
457                                                  'test, e.g. flame-target_files-6935423.zip.'))
458  parser.add_argument('-c', '--current_build',
459                      help=('Optional. When empty, the script will read files in the build from '
460                            'the device via adb. When set, the script will read build files from '
461                            'the file provided by this argument. And this file should be the '
462                            'current build that is flashed to device, such as a target file '
463                            'like flame-target_files-6935424.zip. This argument can be used when '
464                            'some dependencies files are not accessible via adb.'))
465  parser.add_argument('--generate_deps_only', action='store_true',
466                      help=('Run test and generate dEQP dependency list only '
467                            'without comparing build.'))
468  parser.add_argument('--ats_mode', action='store_true',
469                      help=('Run incremental dEQP with Android Test Station.'))
470  parser.add_argument('--custom_handler', action='store_true',
471                      help='Use custome build file handler')
472  return parser
473
474def _create_logger(log_file_name):
475  """Create logger.
476
477  Args:
478    log_file_name: absolute path of the log file.
479  Returns:
480    a logging.Logger
481  """
482  logging.basicConfig(filename=log_file_name)
483  logger = logging.getLogger()
484  logger.setLevel(level=logging.NOTSET)
485  return logger
486
487def _save_deqp_deps(deqp_deps, file_name):
488  """Save dEQP dependency to file.
489
490  Args:
491    deqp_deps: a set of dEQP dependency.
492    file_name: name of the file to save dEQP dependency.
493  Returns:
494    name of the file that saves dEQP dependency.
495  """
496  with open(file_name, 'w') as f:
497    for dep in sorted(deqp_deps):
498      f.write(dep+'\n')
499  return file_name
500
501def _local_run(args, work_dir):
502  """Run incremental dEQP locally.
503
504  Args:
505    args: return of parser.parse_args().
506    work_dir: path of directory for saving script result and logs.
507  """
508  print('Logs and simpleperf results will be copied to: ' + work_dir)
509  if args.test:
510    test_dir = args.test
511  else:
512    test_dir = os.path.dirname(os.path.abspath(__file__))
513  # Extra dEQP dependencies are the files can't be loaded to memory such as firmware.
514  extra_deqp_deps = set()
515  extra_deqp_deps_file = os.path.join(test_dir, 'extra_deqp_dependency.txt')
516  if not os.path.exists(extra_deqp_deps_file):
517    if not args.generate_deps_only:
518      raise TestResourceError('{test_resource} doesn\'t exist'
519                             .format(test_resource=extra_deqp_deps_file))
520  else:
521    with open(extra_deqp_deps_file, 'r') as f:
522      for line in f:
523        extra_deqp_deps.add(line.strip())
524
525  if args.serial:
526    adb = AdbHelper(args.serial)
527  else:
528    adb = AdbHelper()
529
530  dependency_collector = DeqpDependencyCollector(work_dir, test_dir, adb)
531  deqp_deps = dependency_collector.get_deqp_dependency()
532  deqp_deps.update(extra_deqp_deps)
533
534  deqp_deps_file_name = _save_deqp_deps(deqp_deps,
535                                        os.path.join(work_dir, 'dEQP-dependency.txt'))
536  print('dEQP dependency list has been generated in: ' + deqp_deps_file_name)
537
538  if args.generate_deps_only:
539    return
540
541  # Compare the build difference with dEQP dependency
542  valid_deqp_deps = [dep for dep in deqp_deps if _is_deqp_dependency(dep)]
543  build_helper = BuildHelper(args.custom_handler)
544  if args.current_build:
545    skip_dEQP = build_helper.compare_base_build_with_current_build(
546        valid_deqp_deps, args.current_build, args.base_build)
547  else:
548    skip_dEQP = build_helper.compare_base_build_with_device_files(
549        valid_deqp_deps, adb, args.base_build)
550  if skip_dEQP:
551    print('Congratulations, current build could skip dEQP test.\n'
552          'If you run CTS through suite, you could pass filter like '
553          '\'--exclude-filter CtsDeqpTestCases\'.')
554  else:
555    print('Sorry, current build can\'t skip dEQP test because dEQP dependency has been '
556          'changed.\nPlease check logs for more details.')
557
558def _generate_cts_xml(out_dir, content):
559  """Generate cts configuration for Android Test Station.
560
561  Args:
562   out_dir: output directory for cts confiugration.
563   content: configuration content.
564  """
565  with open(os.path.join(out_dir, 'incremental_deqp.xml'), 'w') as f:
566    f.write(content)
567
568
569def _ats_run(args, work_dir):
570  """Run incremental dEQP with Android Test Station.
571
572  Args:
573    args: return of parser.parse_args().
574    work_dir: path of directory for saving script result and logs.
575  """
576  # Extra dEQP dependencies are the files can't be loaded to memory such as firmware.
577  extra_deqp_deps = set()
578  with open(os.path.join(work_dir, 'extra_deqp_dependency.txt'), 'r') as f:
579    for line in f:
580      if line.strip():
581        extra_deqp_deps.add(line.strip())
582
583  android_serials = os.getenv('ANDROID_SERIALS')
584  if not android_serials:
585    raise AtsError('Fail to read environment variable ANDROID_SERIALS.')
586  first_device_serial = android_serials.split(',')[0]
587  adb = AdbHelper(first_device_serial)
588
589  dependency_collector = DeqpDependencyCollector(work_dir,
590                                                 os.path.join(work_dir, 'test_resources'), adb)
591  deqp_deps = dependency_collector.get_deqp_dependency()
592  deqp_deps.update(extra_deqp_deps)
593
594  deqp_deps_file_name = _save_deqp_deps(deqp_deps,
595                                        os.path.join(work_dir, 'dEQP-dependency.txt'))
596
597  if args.generate_deps_only:
598    _generate_cts_xml(work_dir, DEFAULT_CTS_XML)
599    return
600
601  # Compare the build difference with dEQP dependency
602  # base build target file is from test resources.
603  base_build_target = os.path.join(work_dir, 'base_build_target_files')
604  build_helper = BuildHelper(base_build_target, deqp_deps)
605  skip_dEQP = build_helper.compare_build(adb)
606  if skip_dEQP:
607    _generate_cts_xml(work_dir, INCREMENTAL_DEQP_XML)
608  else:
609    _generate_cts_xml(work_dir, DEFAULT_CTS_XML)
610
611def main():
612  parser = _get_parser()
613  args = parser.parse_args()
614  if not args.generate_deps_only and not args.base_build and not args.ats_mode:
615    parser.error('Base build argument: \'-b [file] or --base_build [file]\' '
616                 'is required to compare build.')
617
618  work_dir = ''
619  log_file_name = ''
620  if args.ats_mode:
621    work_dir = os.getenv('TF_WORK_DIR')
622    log_file_name = os.path.join('/data/tmp', 'incremental-deqp-log-'+str(uuid.uuid4()))
623  else:
624    work_dir = tempfile.mkdtemp(prefix='incremental-deqp-'
625                                + time.strftime("%Y%m%d-%H%M%S"))
626    log_file_name = os.path.join(work_dir, 'incremental-deqp-log')
627  global logger
628  logger = _create_logger(log_file_name)
629
630  if args.ats_mode:
631    _ats_run(args, work_dir)
632  else:
633    _local_run(args, work_dir)
634
635if __name__ == '__main__':
636  main()
637
638