# Copyright 2023 the V8 project authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. from abc import ABC, abstractmethod from enum import Enum from pathlib import Path import argparse import logging import re import shutil import sys from blinkpy.common.system.log_utils import configure_logging from blinkpy.w3c.common import read_credentials TEST_FILE_REFERENCE_IN_STATUS_FILE = re.compile("^\s*'(.*)':.*,$") TEST262_FAILURE_LINE = re.compile("=== test262/(.*) ===") TEST262_PATTERN = re.compile('^test/(.*)\.[^\s]*$') TEST262_RENAME_PATTERN = re.compile('^R[^\s]*\s*([^\s]*)\s*([^\s]*)$') TEST262_REPO_URL = 'https://chromium.googlesource.com/external/github.com/tc39/test262' V8_TEST262_ROLLS_META_BUG = 'v8:7834' _log = logging.getLogger(__file__) class GitFileStatus(Enum): ADDED = 'A' DELETED = 'D' MODIFIED = 'M' UNKNOWN = 'X' class V8TestImporter(): def __init__(self, phase, host, test262_github=None, test262_failure_file=None, v8_test262_last_rev=None): self.host = host self.github = test262_github self.project_config = host.project_config self.project_root = Path(self.project_config.project_root) self.test262_status_file = self.project_root / 'test' / 'test262' / 'test262.status' self.phase = phase self.test262_failure_file = test262_failure_file self.v8_test262_last_rev = v8_test262_last_rev def parse_args(self, argv): parser = argparse.ArgumentParser() parser.description = __doc__ parser.add_argument( '-v', '--verbose', action='store_true', help='log extra details that may be helpful when debugging') parser.add_argument( '--credentials-json', help='A JSON file with GitHub credentials, ' 'generally not necessary on developer machines') return parser.parse_args(argv) def main(self, argv=None): options = self.parse_args(argv) self.verbose = options.verbose log_level = logging.DEBUG if self.verbose else logging.INFO configure_logging(logging_level=log_level, include_time=True) credentials = read_credentials(self.host, options.credentials_json) gh_user = credentials.get('GH_USER') gh_token = credentials.get('GH_TOKEN') if not gh_user or not gh_token: _log.warning('You have not set your GitHub credentials. This ' 'script may fail with a network error when making ' 'an API request to GitHub.') _log.warning('See https://chromium.googlesource.com/chromium/src' '/+/main/docs/testing/web_platform_tests.md' '#GitHub-credentials for instructions on how to set ' 'your credentials up.') self.github = self.project_config.github_factory(self.host, gh_user, gh_token) test262_rev = self.fetch_test262(gh_token) v8_test262_rev = ( self.v8_test262_last_rev or self.find_current_test262_rev()) if self.run_prebuild_phase(): if test262_rev == v8_test262_rev: _log.info(f'No changes to import. {test262_rev} == {v8_test262_rev}') return False _log.info(f'Importing test262@{test262_rev} to V8') self.roll_as_dependency(test262_rev) self.overwrite_files() self.sync_folders(v8_test262_rev, test262_rev) failure_lines = [] if self.run_build_phase(): failure_lines = self.build_and_test() # We either have the lines from the build phase or we read them from a file # provided by the executor of POSTBUILD phase. failure_lines = failure_lines or self.failure_lines_from_file() if self.run_postbuild_phase(): self.update_status_file(v8_test262_rev, test262_rev, failure_lines) # Output the import range for the recipe to pick up. print(f'{TEST262_REPO_URL}/+log/{v8_test262_rev[:8]}..{test262_rev[:8]}') if self.run_upload_phase(): self.commit_and_upload_changes(v8_test262_rev, test262_rev) # TODO: Create bug if status update yields a skip block return True @property def test262_path(self): return Path(self.local_test262.path) def run_prebuild_phase(self): return self.phase in ['ALL', 'PREBUILD'] def run_build_phase(self): return self.phase in ['ALL'] def run_postbuild_phase(self): return self.phase in ['ALL', 'POSTBUILD'] def run_upload_phase(self): return self.phase in ['ALL', 'UPLOAD'] def fetch_test262(self, gh_token): _log.info(f'Fetching test262') self.local_test262 = self.project_config.local_repo_factory( self.host, gh_token=gh_token) self.local_test262.fetch() self.test262_git = self.host.git(self.test262_path) return self.test262_git.latest_git_commit() def find_current_test262_rev(self): _log.info(f'Finding current test262 revision in V8') return self.host.executive.run_command( ['gclient', 'getdep', '-r', 'test/test262/data'], cwd=self.project_root).splitlines()[-1].strip() def roll_as_dependency(self, test262_rev): self.host.executive.run_command( ['gclient', 'setdep', '-r', f'test/test262/data@{test262_rev}'], cwd=self.project_root) def overwrite_files(self): for file in self.project_config.files_to_copy: _log.info(f'Overwriting {file.destination} with {file.source}') shutil.copyfile( Path(self.local_test262.path) / file.source, self.project_root / file.destination) def sync_folders(self, v8_test262_rev, test262_rev): for folder in self.project_config.paths_to_sync: _log.info(f'Sync {folder.destination} with {folder.source}') destination = self.project_root / folder.destination for f in destination.glob('./**/*'): if f.is_file() and not self.is_git_sync_exception(f): relative_path = f.relative_to(destination) source_file = self.test262_path / folder.source / relative_path status = self.get_git_file_status(v8_test262_rev, test262_rev, source_file) self.update_file(f, status, source_file) def is_git_sync_exception(self, file): return file.name == 'features.txt' def get_git_file_status(self, v8_test262_rev, test262_rev, source_file): status_line = self.test262_git.run([ 'diff', '--name-status', v8_test262_rev, test262_rev, '--', source_file ]).splitlines() assert len(status_line) < 2, f'Expected zero or one line, got {status_line}' if len(status_line) == 0: return GitFileStatus.UNKNOWN return GitFileStatus(status_line[0][0]) def update_file(self, local_file, status, source_file): gfs = GitFileStatus if status in [gfs.ADDED, gfs.DELETED, gfs.MODIFIED]: _log.info(f'{local_file} has counterpart in Test262. Deleting.') local_file.unlink() else: _log.warning( f'{local_file} has no counterpart in Test262. ' 'Maybe it was never exported?' ) def build_and_test(self): """Builds and run test262 tests V8.""" _log.info('Building V8') build_config = 'x64.release' self.host.executive.run_command( [sys.executable, 'tools/dev/gm.py', build_config], cwd=self.project_root) _log.info('Running test262 tests') test_results = self.host.executive.run_command( [ sys.executable, 'tools/run-tests.py', f'--outdir=out/{build_config}', '--progress=verbose', '--exit-after-n-failures=0', 'test262' ], error_handler=testing_error_handler, cwd=self.project_root).splitlines() failure_matches = [TEST262_FAILURE_LINE.match(l) for l in test_results] return uniq(m.group(1) for m in failure_matches if m) def failure_lines_from_file(self): if not self.test262_failure_file: _log.warning('No failure file provided. Skipping.') return [] with open(self.test262_failure_file, 'r') as r_file: return r_file.readlines() def update_status_file(self, v8_test262_rev, test262_rev, failure_lines): _log.info(f'Updating status file') updated_status = self.remove_and_rename(v8_test262_rev, test262_rev) added_lines = self.failed_tests_to_status_lines(failure_lines) if added_lines: updated_status = self.rewrite_status_file_content(updated_status, added_lines, v8_test262_rev, test262_rev) with open(self.test262_status_file, 'w') as w_file: w_file.writelines(updated_status) def remove_and_rename(self, v8_test262_rev, test262_rev): remover = DeletedTestsRemover(self, v8_test262_rev, test262_rev) renamer = RenamedTestsUpdater(self, v8_test262_rev, test262_rev) def updated_line(line): return remover.updated_line(renamer.updated_line(line)) updated_status = [updated_line(line) for line in self.status_file_lines()] return [line for line in updated_status if line is not None] def status_file_lines(self): with open(self.test262_status_file, 'r') as r_file: return r_file.readlines() def failed_tests_to_status_lines(self, failed_tests): # Transform the list of failed tests into a list of status file lines. return [f" '{test}': [FAIL],\n" for test in failed_tests] def rewrite_status_file_content(self, updated_status, added_lines, v8_test262_rev, test262_rev): # Reassemble the status file with the new tests added. # TODO(liviurau): This is easy to unit test. Add unit tests. status_lines_before_eof = updated_status[:-2] eof_status_lines = updated_status[-2:] assert eof_status_lines == [ '\n', ']\n' ], f'Unexpected status file eof. {eof_status_lines}' import_header_lines = [ '\n####\n', f'# Import test262@{test262_rev[:8]}\n', f'# {TEST262_REPO_URL}/+log/{v8_test262_rev[:8]}..{test262_rev[:8]}\n' ] new_failing_tests_lines = ['[ALWAYS, {\n'] + added_lines + [ '}],\n', f'# End import test262@{test262_rev[:8]}\n', '####\n' ] return (status_lines_before_eof + import_header_lines + new_failing_tests_lines + eof_status_lines) def commit_and_upload_changes(self, v8_test262_rev, test262_rev): _log.info('Committing changes.') self.project_git.run([ 'commit', '-a', '-m', '[test262] Roll test262', '-m', f'{TEST262_REPO_URL}/+log/{v8_test262_rev[:8]}..{test262_rev[:8]}', '-m', 'no-export: true', ]) _log.info('Uploading changes.') self.project_git.run([ 'cl', 'upload', '--bypass-hooks', '-f', '-b', V8_TEST262_ROLLS_META_BUG, '-d', ]) _log.info(f'Issue: {self.project_git.run(["cl", "issue"]).strip()}') def uniq(lst): """Return a list with unique elements from the input list.""" return sorted(set(lst)) def testing_error_handler(error): """Error handler that does nothing; used to suppress errors.""" pass def relative_test_name(name): """Remove the test262 prefix from the test name.""" return re.sub(TEST262_PATTERN, r'\1', name) class StatusFileUpdater(ABC): def __init__(self, importer, v8_test262_rev, test262_rev, diff_args): self.importer = importer self.diff_args = diff_args self.to_update = self.collect_updateable_tests(v8_test262_rev, test262_rev) def updated_line(self, line): test_name_result = TEST_FILE_REFERENCE_IN_STATUS_FILE.match(line) if test_name_result: return self.update_test_line(line, test_name_result) return line def collect_updateable_tests(self, v8_test262_rev, test262_rev): lines = self.importer.test262_git.run([ 'diff', *self.diff_args, v8_test262_rev, test262_rev, '--', 'test' ]).splitlines() return self.process_git_output(lines) @abstractmethod def process_git_output(self, lines): pass @abstractmethod def update_test_line(self, line, test_name_result): pass class DeletedTestsRemover(StatusFileUpdater): def __init__(self, importer, v8_test262_rev, test262_rev): super().__init__(importer, v8_test262_rev, test262_rev, ['--name-only', '--diff-filter=D']) def process_git_output(self, lines): return [ relative_test_name(line) for line in lines if TEST262_PATTERN.match(line) ] def update_test_line(self, line, test_name_result): test_name = test_name_result.group(1) if test_name not in self.to_update: return line _log.info(f'... removing {test_name}') return None class RenamedTestsUpdater(StatusFileUpdater): def __init__(self, importer, v8_test262_rev, test262_rev): super().__init__(importer, v8_test262_rev, test262_rev, ['--name-status', '--diff-filter=R']) def process_git_output(self, lines): search_renames = [re.search(TEST262_RENAME_PATTERN, line) for line in lines] renames = { rename.group(1): rename.group(2) for rename in search_renames if rename } return { relative_test_name(key): relative_test_name(value) for key, value in renames.items() if TEST262_PATTERN.match(key) and TEST262_PATTERN.match(value) } def update_test_line(self, line, test_name_result): old_name = test_name_result.group(1) new_name = self.to_update.get(old_name) if not new_name: return line _log.info(f'... updating {old_name} to {new_name}') return line.replace(old_name, new_name)