Buckets:
| # Copyright 2025 The Emscripten Authors. All rights reserved. | |
| # Emscripten is available under two separate licenses, the MIT license and the | |
| # University of Illinois/NCSA Open Source License. Both these licenses can be | |
| # found in the LICENSE file. | |
| import atexit | |
| import logging | |
| import os | |
| import plistlib | |
| import queue | |
| import re | |
| import shlex | |
| import shutil | |
| import subprocess | |
| import threading | |
| import time | |
| import webbrowser | |
| from enum import Enum | |
| from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer | |
| from urllib.parse import parse_qs, unquote, unquote_plus, urlparse | |
| import common | |
| import psutil | |
| from common import ( | |
| TEST_ROOT, | |
| RunnerCore, | |
| compiler_for, | |
| copy_asset, | |
| create_file, | |
| errlog, | |
| force_delete_dir, | |
| maybe_test_file, | |
| read_file, | |
| record_flaky_test, | |
| test_file, | |
| ) | |
| from tools import feature_matrix, utils | |
| from tools.feature_matrix import OLDEST_SUPPORTED_FIREFOX, UNSUPPORTED | |
| from tools.shared import DEBUG, EMCC, exit_with_error | |
| from tools.utils import LINUX, MACOS, WINDOWS, memoize, path_from_root, read_binary | |
| logger = logging.getLogger('common') | |
| # User can specify an environment variable EMTEST_BROWSER to force the browser | |
| # test suite to run using another browser command line than the default system | |
| # browser. If only the path to the browser executable is given, the tests | |
| # will run in headless mode with a temporary profile with the same options | |
| # used in CI. To use a custom start command specify the executable and command | |
| # line flags. | |
| # | |
| # Note that when specifying EMTEST_BROWSER to run tests on a Safari browser: | |
| # the command line must point to the root of the app bundle, and not to the | |
| # Safari executable inside the bundle. I.e. pass EMTEST_BROWSER=/Applications/Safari.app | |
| # instead of EMTEST_BROWSER=/Applications/Safari.app/Contents/MacOS/Safari | |
| # | |
| # There are two special values that can be used here if running in an actual | |
| # browser is not desired: | |
| # EMTEST_BROWSER=0 : This will disable the actual running of the test and simply | |
| # verify that it compiles and links. | |
| # EMTEST_BROWSER=node : This will attempt to run the browser test under node. | |
| # For most browser tests this does not work, but it can | |
| # be useful for running pthread tests under node. | |
| EMTEST_BROWSER = None | |
| EMTEST_BROWSER_AUTO_CONFIG = None | |
| EMTEST_HEADLESS = None | |
| EMTEST_CAPTURE_STDIO = int(os.getenv('EMTEST_CAPTURE_STDIO', '0')) | |
| # Triggers the browser to restart after every given number of tests. | |
| # 0: Disabled (reuse the browser instance to run all tests. Default) | |
| # 1: Restart a fresh browser instance for every browser test. | |
| # 2,3,...: Restart a fresh browser instance after given number of tests have been run in it. | |
| # Helps with e.g. https://bugzil.la/1992558 | |
| EMTEST_RESTART_BROWSER_EVERY_N_TESTS = int(os.getenv('EMTEST_RESTART_BROWSER_EVERY_N_TESTS', '0')) | |
| DEFAULT_BROWSER_DATA_DIR = path_from_root('out/browser-profile') | |
| browser_spawn_lock_filename = path_from_root('out/browser_spawn_lock') | |
| class Reporting(Enum): | |
| """Browser reporting method. | |
| When running browser tests we normally automatically include support | |
| code for reporting results back to the browser. This enum allows tests | |
| to decide what type of support code they need/want. | |
| """ | |
| NONE = 0 | |
| # Include the JS helpers for reporting results | |
| JS_ONLY = 1 | |
| # Include C/C++ reporting code (REPORT_RESULT macros) as well as JS helpers | |
| FULL = 2 | |
| def list_processes_by_name(exe_name): | |
| pids = [] | |
| if exe_name: | |
| for proc in psutil.process_iter(): | |
| try: | |
| pinfo = proc.as_dict(attrs=['pid', 'name', 'exe']) | |
| if pinfo['exe'] and exe_name in pinfo['exe'].replace('\\', '/').split('/'): | |
| pids.append(psutil.Process(pinfo['pid'])) | |
| except psutil.NoSuchProcess: # E.g. "process no longer exists (pid=13132)" (code raced to acquire the iterator and process it) | |
| pass | |
| return pids | |
| def terminate_list_of_processes(proc_list): | |
| for proc in proc_list: | |
| try: | |
| proc.terminate() | |
| # If the browser doesn't shut down gracefully (in response to SIGTERM) | |
| # after 2 seconds kill it with force (SIGKILL). | |
| try: | |
| proc.wait(2) | |
| except (subprocess.TimeoutExpired, psutil.TimeoutExpired): | |
| logger.info('Browser did not respond to `terminate`. Using `kill`') | |
| proc.kill() | |
| proc.wait() | |
| except (psutil.NoSuchProcess, ProcessLookupError): | |
| pass | |
| def init(force_browser_process_termination): | |
| utils.delete_file(browser_spawn_lock_filename) | |
| utils.delete_file(f'{browser_spawn_lock_filename}_counter') | |
| if force_browser_process_termination or os.getenv('EMTEST_FORCE_BROWSER_PROCESS_TERMINATION'): | |
| config = get_browser_config() | |
| if config and hasattr(config, 'executable_name'): | |
| def terminate_all_browser_processes(): | |
| procs = list_processes_by_name(config.executable_name) | |
| if len(procs) > 0: | |
| print(f'Terminating {len(procs)} stray browser processes.') | |
| terminate_list_of_processes(procs) | |
| atexit.register(terminate_all_browser_processes) | |
| terminate_all_browser_processes() | |
| def find_browser_test_file(filename): | |
| """Looks for files in test/browser and then in test/.""" | |
| if not os.path.exists(filename): | |
| fullname = test_file('browser', filename) | |
| if not os.path.exists(fullname): | |
| fullname = test_file(filename) | |
| filename = fullname | |
| return filename | |
| def get_safari_version(): | |
| if not is_safari(): | |
| return UNSUPPORTED | |
| plist_path = os.path.join(EMTEST_BROWSER.strip(), 'Contents', 'version.plist') | |
| version_str = plistlib.load(open(plist_path, 'rb')).get('CFBundleShortVersionString') | |
| # Split into parts (major.minor.patch) | |
| parts = (version_str.split('.') + ['0', '0', '0'])[:3] | |
| # Convert each part into integers, discarding any trailing string, e.g. '13a' -> 13. | |
| parts = [int(re.match(r"\d+", s).group()) if re.match(r"\d+", s) else 0 for s in parts] | |
| # Return version as XXYYZZ | |
| return parts[0] * 10000 + parts[1] * 100 + parts[2] | |
| def get_firefox_version(): | |
| if not is_firefox(): | |
| return UNSUPPORTED | |
| exe_path = shutil.which(shlex.split(EMTEST_BROWSER)[0]) | |
| ini_path = os.path.join(os.path.dirname(exe_path), '../Resources/platform.ini' if MACOS else 'platform.ini') | |
| # On Linux, Firefox system installation uses a specific directory structure, | |
| # where platform.ini is not located in same directory as the browser executable. | |
| if LINUX and exe_path.startswith('/usr/bin/'): | |
| def find_system_firefox_platform_ini(): | |
| for path in ['/usr/lib/firefox-esr/', '/usr/lib/firefox/']: | |
| ini = os.path.join(path, 'platform.ini') | |
| if os.path.isfile(ini): | |
| return ini | |
| ini_path = find_system_firefox_platform_ini() | |
| if not ini_path: | |
| logger.warning(f'Firefox browser detected in {EMTEST_BROWSER}, but could not find Firefox platform.ini to detect Firefox version. Assuming OLDEST_SUPPORTED_FIREFOX={OLDEST_SUPPORTED_FIREFOX}') | |
| return OLDEST_SUPPORTED_FIREFOX | |
| # Extract the first numeric part before any dot (e.g. "Milestone=102.15.1" → 102) | |
| m = re.search(r"^Milestone=(.*)$", read_file(ini_path), re.MULTILINE) | |
| milestone = m.group(1).strip() | |
| version = int(re.match(r"(\d+)", milestone).group(1)) | |
| # On Nightly and Beta, e.g. 145.0a1, pretend it to still mean version 144, | |
| # since it is a pre-release version | |
| if any(c in milestone for c in ('a', 'b')): | |
| version -= 1 | |
| return version | |
| def browser_should_skip_feature(skip_env_var, feature): | |
| # If an env. var. EMTEST_LACKS_x to skip the given test is set (to either | |
| # value 0 or 1), don't bother checking if current browser supports the feature | |
| # - just unconditionally run the test, or skip the test. | |
| if os.getenv(skip_env_var) is not None: | |
| return int(os.getenv(skip_env_var)) != 0 | |
| # If there is no Feature object associated with this capability, then we | |
| # should run the test. | |
| if feature is None: | |
| return False | |
| # If EMTEST_AUTOSKIP=0, also never skip. | |
| if os.getenv('EMTEST_AUTOSKIP') == '0': | |
| return False | |
| # Otherwise EMTEST_AUTOSKIP=1 or EMTEST_AUTOSKIP is not set: check whether | |
| # the current browser supports the test or not. | |
| min_required = feature_matrix.min_browser_versions[feature] | |
| not_supported = get_firefox_version() < min_required['firefox'] or get_safari_version() < min_required['safari'] | |
| # Current browser does not support the test, and EMTEST_AUTOSKIP is not set? | |
| # Then error out to have end user decide what to do in this situation. | |
| if not_supported and os.getenv('EMTEST_AUTOSKIP') is None: | |
| return 'error' | |
| # Report whether to skip the test based on browser support. | |
| return not_supported | |
| # Default flags used to run browsers in CI testing: | |
| class ChromeConfig: | |
| data_dir_flag = '--user-data-dir=' | |
| default_flags = ( | |
| # --no-sandbox because we are running as root and chrome requires | |
| # this flag for now: https://crbug.com/638180 | |
| '--no-first-run -start-maximized --no-sandbox --enable-unsafe-swiftshader --use-gl=swiftshader --enable-experimental-web-platform-features --enable-features=JavaScriptSourcePhaseImports', | |
| '--enable-experimental-webassembly-features --js-flags="--experimental-wasm-type-reflection"', | |
| # The runners lack sound hardware so fallback to a dummy device (and | |
| # bypass the user gesture so audio tests work without interaction) | |
| '--use-fake-device-for-media-stream --autoplay-policy=no-user-gesture-required', | |
| # Cache options. | |
| '--disk-cache-size=1 --media-cache-size=1 --disable-application-cache', | |
| # Disable various background tasks downloads (e.g. updates). | |
| '--disable-background-networking', | |
| # Disable native password pop-ups | |
| '--password-store=basic', | |
| # Send console messages to browser stderr | |
| '--enable-logging=stderr', | |
| ) | |
| headless_flags = '--headless=new --window-size=1024,768' | |
| def configure(data_dir): | |
| """Chrome has no special configuration step.""" | |
| def open_url_args(url): | |
| return [url] | |
| class FirefoxConfig: | |
| data_dir_flag = '-profile ' | |
| default_flags = ('-new-instance', '-wait-for-browser') | |
| headless_flags = '-headless' | |
| executable_name = common.exe_suffix('firefox') | |
| def configure(data_dir): | |
| copy_asset('firefox_user.js', os.path.join(data_dir, 'user.js')) | |
| def open_url_args(url): | |
| # Firefox is able to launch URLs by passing them as positional arguments, | |
| # but not when the -wait-for-browser flag is in use (which we need to be | |
| # able to track browser liveness). So explicitly use -url option parameter | |
| # to specify the page to launch. https://bugzil.la/1996614 | |
| return ['-url', url] | |
| class SafariConfig: | |
| default_flags = ('', ) | |
| executable_name = 'Safari' | |
| # For the macOS 'open' command, pass | |
| # --new: to make a new Safari app be launched, rather than add a tab to an existing Safari process/window | |
| # --fresh: do not restore old tabs (e.g. if user had old navigated windows open) | |
| # --background: Open the new Safari window behind the current Terminal window, to make following the test run more pleasing (this is for convenience only) | |
| # -a <exe_name>: The path to the executable to open, in this case Safari | |
| launch_prefix = ('open', '--new', '--fresh', '--background', '-a') | |
| def configure(data_dir): | |
| """Safari has no special configuration step.""" | |
| def open_url_args(url): | |
| return [url] | |
| # checks if browser testing is enabled | |
| def has_browser(): | |
| return EMTEST_BROWSER != '0' | |
| def get_browser(): | |
| return EMTEST_BROWSER | |
| CHROMIUM_BASED_BROWSERS = ['chrom', 'edge', 'opera'] | |
| def is_chrome(): | |
| return EMTEST_BROWSER and any(pattern in EMTEST_BROWSER.lower() for pattern in CHROMIUM_BASED_BROWSERS) | |
| def is_firefox(): | |
| return EMTEST_BROWSER and 'firefox' in EMTEST_BROWSER.lower() | |
| def is_safari(): | |
| return EMTEST_BROWSER and 'safari' in EMTEST_BROWSER.lower() | |
| def get_browser_config(): | |
| if is_chrome(): | |
| return ChromeConfig() | |
| elif is_firefox(): | |
| return FirefoxConfig() | |
| elif is_safari(): | |
| return SafariConfig() | |
| return None | |
| def configure_test_browser(): | |
| global EMTEST_BROWSER | |
| if not has_browser(): | |
| return | |
| if not EMTEST_BROWSER: | |
| EMTEST_BROWSER = 'google-chrome' | |
| if not shutil.which(EMTEST_BROWSER): | |
| EMTEST_BROWSER = 'firefox' | |
| if not shutil.which(EMTEST_BROWSER): | |
| # FIXME: This should really be and error, but this code currently also runs for non-browser tests. | |
| EMTEST_BROWSER = 'default-browser-not-found' | |
| if WINDOWS and '"' not in EMTEST_BROWSER and "'" not in EMTEST_BROWSER: | |
| # On Windows env. vars canonically use backslashes as directory delimiters, e.g. | |
| # set EMTEST_BROWSER=C:\Program Files\Mozilla Firefox\firefox.exe | |
| # and spaces are not escaped. But make sure to also support args, e.g. | |
| # set EMTEST_BROWSER="C:\Users\clb\AppData\Local\Google\Chrome SxS\Application\chrome.exe" --enable-unsafe-webgpu | |
| EMTEST_BROWSER = '"' + EMTEST_BROWSER.replace("\\", "\\\\") + '"' | |
| if EMTEST_BROWSER_AUTO_CONFIG: | |
| config = get_browser_config() | |
| if config: | |
| EMTEST_BROWSER += ' ' + ' '.join(config.default_flags) | |
| if EMTEST_HEADLESS == 1: | |
| EMTEST_BROWSER += f" {config.headless_flags}" | |
| # Create a server and a web page. When a test runs, we tell the server about it, | |
| # which tells the web page, which then opens a window with the test. Doing | |
| # it this way then allows the page to close() itself when done. | |
| def make_test_server(in_queue, out_queue, port): | |
| class TestServerHandler(SimpleHTTPRequestHandler): | |
| # Request header handler for default do_GET() path in | |
| # SimpleHTTPRequestHandler.do_GET(self) below. | |
| def send_head(self): | |
| if self.headers.get('Range'): | |
| path = self.translate_path(self.path) | |
| try: | |
| fsize = os.path.getsize(path) | |
| f = open(path, 'rb') | |
| except OSError: | |
| self.send_error(404, f'File not found {path}') | |
| return None | |
| self.send_response(206) | |
| ctype = self.guess_type(path) | |
| self.send_header('Content-Type', ctype) | |
| pieces = self.headers.get('Range').split('=')[1].split('-') | |
| start = int(pieces[0]) if pieces[0] else 0 | |
| end = int(pieces[1]) if pieces[1] else fsize - 1 | |
| end = min(fsize - 1, end) | |
| length = end - start + 1 | |
| self.send_header('Content-Range', f'bytes {start}-{end}/{fsize}') | |
| self.send_header('Content-Length', str(length)) | |
| self.end_headers() | |
| return f | |
| else: | |
| return SimpleHTTPRequestHandler.send_head(self) | |
| # Add COOP, COEP, CORP, and no-caching headers | |
| def end_headers(self): | |
| self.send_header('Accept-Ranges', 'bytes') | |
| self.send_header('Access-Control-Allow-Origin', '*') | |
| self.send_header('Cross-Origin-Opener-Policy', 'same-origin') | |
| self.send_header('Cross-Origin-Embedder-Policy', 'require-corp') | |
| self.send_header('Cross-Origin-Resource-Policy', 'cross-origin') | |
| self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate, private, max-age=0') | |
| self.send_header('Expires', '0') | |
| self.send_header('Pragma', 'no-cache') | |
| self.send_header('Vary', '*') # Safari insists on caching if this header is not present in addition to the above | |
| return SimpleHTTPRequestHandler.end_headers(self) | |
| def do_POST(self): # noqa: DC04 | |
| urlinfo = urlparse(self.path) | |
| query = parse_qs(urlinfo.query) | |
| content_length = int(self.headers['Content-Length']) | |
| post_data = self.rfile.read(content_length) | |
| if urlinfo.path == '/log': | |
| # Logging reported by reportStdoutToServer / reportStderrToServer. | |
| # | |
| # To automatically capture stderr/stdout message from browser tests, modify | |
| # `captureStdoutStderr` in `test/browser_reporting.js`. | |
| filename = query['file'][0] | |
| print(f"[client {filename}: '{post_data.decode()}']") | |
| self.send_response(200) | |
| self.end_headers() | |
| elif urlinfo.path == '/upload': | |
| filename = query['file'][0] | |
| print(f'do_POST: got file: {filename}') | |
| create_file(filename, post_data, binary=True) | |
| self.send_response(200) | |
| self.end_headers() | |
| elif urlinfo.path.startswith('/status/'): | |
| code_str = urlinfo.path[len('/status/'):] | |
| code = int(code_str) | |
| if code in {301, 302, 303, 307, 308}: | |
| self.send_response(code) | |
| self.send_header('Location', '/status/200') | |
| self.end_headers() | |
| elif code == 200: | |
| self.send_response(200) | |
| self.send_header('Content-type', 'text/plain') | |
| self.end_headers() | |
| self.wfile.write(b'OK') | |
| else: | |
| self.send_error(400, f'Not implemented for {code}') | |
| else: | |
| print(f'do_POST: unexpected POST: {urlinfo}') | |
| def do_GET(self): | |
| info = urlparse(self.path) | |
| if info.path == '/run_harness': | |
| if DEBUG: | |
| print('[server startup]') | |
| self.send_response(200) | |
| self.send_header('Content-type', 'text/html') | |
| self.end_headers() | |
| self.wfile.write(read_binary(test_file('browser_harness.html'))) | |
| elif info.path.startswith('/status/'): | |
| code_str = info.path[len('/status/'):] | |
| code = int(code_str) | |
| if code in {301, 302, 303, 307, 308}: | |
| # Redirect to /status/200 | |
| self.send_response(code) | |
| self.send_header('Location', '/status/200') | |
| self.end_headers() | |
| elif code == 200: | |
| self.send_response(200) | |
| self.send_header('Content-type', 'text/plain') | |
| self.end_headers() | |
| self.wfile.write(b'OK') | |
| else: | |
| self.send_error(400, f'Not implemented for {code}') | |
| elif 'report_' in self.path: | |
| # for debugging, tests may encode the result and their own url (window.location) as result|url | |
| if '|' in self.path: | |
| path, url = self.path.split('|', 1) | |
| else: | |
| path = self.path | |
| url = '?' | |
| if DEBUG: | |
| print('[server response:', path, url, ']') | |
| if out_queue.empty(): | |
| out_queue.put(path) | |
| else: | |
| # a badly-behaving test may send multiple xhrs with reported results; we just care | |
| # about the first (if we queued the others, they might be read as responses for | |
| # later tests, or maybe the test sends more than one in a racy manner). | |
| # we place 'None' in the queue here so that the outside knows something went wrong | |
| # (none is not a valid value otherwise; and we need the outside to know because if we | |
| # raise an error in here, it is just swallowed in python's webserver code - we want | |
| # the test to actually fail, which a webserver response can't do). | |
| out_queue.put(None) | |
| raise Exception('browser harness error, excessive response to server - test must be fixed! "%s"' % self.path) | |
| self.send_response(200) | |
| self.send_header('Content-type', 'text/plain') | |
| self.send_header('Connection', 'close') | |
| self.end_headers() | |
| self.wfile.write(b'OK') | |
| elif info.path == '/check': | |
| self.send_response(200) | |
| self.send_header('Content-type', 'text/html') | |
| self.end_headers() | |
| if not in_queue.empty(): | |
| # there is a new test ready to be served | |
| url, dir = in_queue.get() | |
| if DEBUG: | |
| print('[queue command:', url, dir, ']') | |
| assert in_queue.empty(), 'should not be any blockage - one test runs at a time' | |
| assert out_queue.empty(), 'the single response from the last test was read' | |
| # tell the browser to load the test | |
| self.wfile.write(b'COMMAND:' + url.encode('utf-8')) | |
| else: | |
| # the browser must keep polling | |
| self.wfile.write(b'(wait)') | |
| else: | |
| # Use SimpleHTTPServer default file serving operation for GET. | |
| if DEBUG: | |
| print('[simple HTTP serving:', unquote_plus(self.path), ']') | |
| if self.headers.get('Range'): | |
| self.send_response(206) | |
| path = self.translate_path(self.path) | |
| data = read_binary(path) | |
| ctype = self.guess_type(path) | |
| self.send_header('Content-type', ctype) | |
| pieces = self.headers.get('Range').split('=')[1].split('-') | |
| start = int(pieces[0]) if pieces[0] else 0 | |
| end = int(pieces[1]) if pieces[1] else len(data) - 1 | |
| end = min(len(data) - 1, end) | |
| length = end - start + 1 | |
| self.send_header('Content-Length', str(length)) | |
| self.send_header('Content-Range', f'bytes {start}-{end}/{len(data)}') | |
| self.end_headers() | |
| self.wfile.write(data[start:end + 1]) | |
| else: | |
| SimpleHTTPRequestHandler.do_GET(self) | |
| def log_request(code=0, size=0): | |
| # don't log; too noisy | |
| pass | |
| # allows streaming compilation to work | |
| SimpleHTTPRequestHandler.extensions_map['.wasm'] = 'application/wasm' | |
| # Firefox browser security does not allow loading .mjs files if they | |
| # do not have the correct MIME type | |
| SimpleHTTPRequestHandler.extensions_map['.mjs'] = 'text/javascript' | |
| return ThreadingHTTPServer(('localhost', port), TestServerHandler) | |
| class HttpServerThread(threading.Thread): | |
| """A generic thread class to create and run an http server.""" | |
| def __init__(self, server): | |
| super().__init__() | |
| self.server = server | |
| def stop(self): | |
| """Shuts down the server if it is running.""" | |
| self.server.shutdown() | |
| def run(self): | |
| """Create a server instance and serve forever until stop() is called.""" | |
| # Start the server's main loop (this blocks until shutdown() is called) | |
| self.server.serve_forever() | |
| # This will hold the ID for each worker process if running in parallel mode, | |
| # otherwise None if running in non-parallel mode. | |
| worker_id = None | |
| def init_worker(counter, lock): | |
| """Initializer function for each worker. | |
| It acquires a lock, gets a unique ID from the shared counter, | |
| and stores it in a global variable specific to this worker process. | |
| """ | |
| global worker_id | |
| with lock: | |
| # Get the next available ID | |
| worker_id = counter.value | |
| # Increment the counter for the next worker | |
| counter.value += 1 | |
| def move_browser_window(pid, x, y): | |
| """Utility function to move the top-level window. | |
| Move the windows owned by given process to (x,y) coordinate. | |
| Used to ensure each browser window has some visible area. | |
| """ | |
| import win32con | |
| import win32gui | |
| import win32process | |
| def enum_windows_callback(hwnd, _unused): | |
| _, win_pid = win32process.GetWindowThreadProcessId(hwnd) | |
| if win_pid == pid and win32gui.IsWindowVisible(hwnd): | |
| # If the browser window is maximized, it won't react to MoveWindow, so | |
| # un-maximize the window first to show it in windowed mode. | |
| if win32gui.GetWindowPlacement(hwnd)[1] == win32con.SW_SHOWMAXIMIZED: | |
| win32gui.ShowWindow(hwnd, win32con.SW_RESTORE) | |
| # Then cascade the window, but also resize the window size to cover a | |
| # smaller area of the desktop, in case the original size was full screen. | |
| win32gui.MoveWindow(hwnd, x, y, 800, 600, True) | |
| return True | |
| win32gui.EnumWindows(enum_windows_callback, None) | |
| def increment_suffix_number(str_with_maybe_suffix): | |
| match = re.match(r"^(.*?)(?:_(\d+))?$", str_with_maybe_suffix) | |
| if match: | |
| base, number = match.groups() | |
| if number: | |
| return f'{base}_{int(number) + 1}' | |
| return f'{str_with_maybe_suffix}_1' | |
| class FileLock: | |
| """Implements a filesystem-based mutex. | |
| In additon the context manager returns an integer counter denoting how | |
| many times the lock has been locked before (during the current python test | |
| run instance) | |
| """ | |
| def __init__(self, path): | |
| self.path = path | |
| self.counter = 0 | |
| def __enter__(self): | |
| # Acquire the lock | |
| while True: | |
| try: | |
| self.fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) | |
| break | |
| except FileExistsError: | |
| time.sleep(0.1) | |
| # Return the locking count number | |
| try: | |
| self.counter = int(utils.read_file(f'{self.path}_counter')) | |
| except Exception: | |
| pass | |
| return self.counter | |
| def __exit__(self, *a): | |
| # Increment locking count number before releasing the lock | |
| utils.write_file(f'{self.path}_counter', str(self.counter + 1)) | |
| # And release the lock | |
| os.close(self.fd) | |
| try: | |
| os.remove(self.path) | |
| except Exception: | |
| pass # Another process has raced to acquire the lock, and will delete it. | |
| class BrowserCore(RunnerCore): | |
| # note how many tests hang / do not send an output. if many of these | |
| # happen, likely something is broken and it is best to abort the test | |
| # suite early, as otherwise we will wait for the timeout on every | |
| # single test (hundreds of minutes) | |
| MAX_UNRESPONSIVE_TESTS = 10 | |
| BROWSER_TIMEOUT = 60 | |
| unresponsive_tests = 0 | |
| num_tests_ran = 0 | |
| def __init__(self, *args, **kwargs): | |
| self.capture_stdio = EMTEST_CAPTURE_STDIO | |
| super().__init__(*args, **kwargs) | |
| def browser_terminate(cls): | |
| terminate_list_of_processes(cls.browser_procs) | |
| def browser_restart(cls): | |
| # Kill existing browser | |
| assert has_browser() | |
| logger.info('Restarting browser process') | |
| cls.browser_terminate() | |
| cls.browser_open(cls.HARNESS_URL) | |
| BrowserCore.num_tests_ran = 0 | |
| def browser_open(cls, url): | |
| assert has_browser() | |
| browser_args = EMTEST_BROWSER | |
| parallel_harness = worker_id is not None | |
| config = get_browser_config() | |
| if not config and EMTEST_BROWSER_AUTO_CONFIG: | |
| exit_with_error(f'EMTEST_BROWSER_AUTO_CONFIG only currently works with firefox, chrome and safari. EMTEST_BROWSER was "{EMTEST_BROWSER}"') | |
| # Prepare the browser data directory, if it uses one. | |
| if EMTEST_BROWSER_AUTO_CONFIG and config and hasattr(config, 'data_dir_flag'): | |
| logger.info('Using default CI configuration.') | |
| browser_data_dir = DEFAULT_BROWSER_DATA_DIR | |
| if parallel_harness: | |
| # Running in parallel mode, give each browser its own profile dir. | |
| browser_data_dir += '-' + str(worker_id) | |
| # Delete old browser data directory. | |
| if WINDOWS: | |
| # If we cannot (the data dir is in use on Windows), switch to another dir. | |
| while not force_delete_dir(browser_data_dir): | |
| browser_data_dir = increment_suffix_number(browser_data_dir) | |
| else: | |
| force_delete_dir(browser_data_dir) | |
| # Recreate the new data directory. | |
| os.mkdir(browser_data_dir) | |
| if WINDOWS: | |
| # Escape directory delimiter backslashes for shlex.split. | |
| browser_data_dir = browser_data_dir.replace('\\', '\\\\') | |
| config.configure(browser_data_dir) | |
| browser_args += f' {config.data_dir_flag}"{browser_data_dir}"' | |
| browser_args = shlex.split(browser_args) | |
| if hasattr(config, 'launch_prefix'): | |
| browser_args = list(config.launch_prefix) + browser_args | |
| logger.info('Launching browser: %s', str(browser_args)) | |
| if (WINDOWS and is_firefox()) or is_safari(): | |
| cls.launch_browser_harness_with_proc_snapshot_workaround(parallel_harness, config, browser_args, url) | |
| else: | |
| cls.browser_procs = [subprocess.Popen(browser_args + config.open_url_args(url))] | |
| def launch_browser_harness_with_proc_snapshot_workaround(cls, parallel_harness, config, browser_args, url): | |
| """Launch a browser using before-after subprocess snapshotting. | |
| Dedicated function for launching browser harness in scenarios where | |
| we need to identify the launched browser processes via a before-after | |
| subprocess snapshotting delta workaround. | |
| """ | |
| # In order for this to work, each browser needs to be launched one at a time | |
| # so that we know which process belongs to which browser. | |
| with FileLock(browser_spawn_lock_filename) as count: | |
| # Take a snapshot before spawning the browser to find which processes | |
| # existed before launching the browser. | |
| if parallel_harness or is_safari(): | |
| procs_before = list_processes_by_name(config.executable_name) | |
| # Browser launch | |
| cls.browser_procs = [subprocess.Popen(browser_args + config.open_url_args(url))] | |
| # Give the browser time to spawn its subprocesses. Use an increasing | |
| # timeout as a crude way to account for system load. | |
| if parallel_harness or is_safari(): | |
| time.sleep(min(5 + count * 0.3, 10)) | |
| procs_after = list_processes_by_name(config.executable_name) | |
| # Take a snapshot again to find which processes exist after launching | |
| # the browser. Then the newly launched browser processes are determined | |
| # by the delta before->after. | |
| cls.browser_procs += list(set(procs_after).difference(set(procs_before))) | |
| if len(cls.browser_procs) == 0: | |
| exit_with_error('Could not detect the launched browser subprocesses. The test harness will not be able to close the browser after testing is done, so aborting the test run here.') | |
| # Firefox on Windows quirk: | |
| # Make sure that each browser window is visible on the desktop. Otherwise | |
| # browser might decide that the tab is backgrounded, and not load a test, | |
| # or it might not tick rAF()s forward, causing tests to hang. | |
| if WINDOWS and parallel_harness and not EMTEST_HEADLESS: | |
| # Wrap window positions on a Full HD desktop area modulo primes. | |
| for proc in cls.browser_procs: | |
| move_browser_window(proc.pid, (300 + count * 47) % 1901, (10 + count * 37) % 997) | |
| def setUpClass(cls): | |
| super().setUpClass() | |
| cls.PORT = 8888 + (0 if worker_id is None else worker_id) | |
| cls.SERVER_URL = f'http://localhost:{cls.PORT}' | |
| cls.HARNESS_URL = f'{cls.SERVER_URL}/run_harness' | |
| if not has_browser() or EMTEST_BROWSER == 'node': | |
| errlog(f'[Skipping browser launch (EMTEST_BROWSER={EMTEST_BROWSER})]') | |
| return | |
| cls.harness_in_queue = queue.Queue() | |
| cls.harness_out_queue = queue.Queue() | |
| cls.harness_server = HttpServerThread(make_test_server(cls.harness_in_queue, cls.harness_out_queue, cls.PORT)) | |
| cls.harness_server.start() | |
| errlog(f'[Browser harness server on thread {cls.harness_server.name}]') | |
| cls.browser_open(cls.HARNESS_URL) | |
| def tearDownClass(cls): | |
| super().tearDownClass() | |
| if not has_browser() or EMTEST_BROWSER == 'node': | |
| return | |
| cls.harness_server.stop() | |
| cls.harness_server.join() | |
| cls.browser_terminate() | |
| if WINDOWS: | |
| # On Windows, shutil.rmtree() in tearDown() raises this exception if we do not wait a bit: | |
| # WindowsError: [Error 32] The process cannot access the file because it is being used by another process. | |
| time.sleep(0.1) | |
| def is_browser_test(self): | |
| return True | |
| def add_browser_reporting(self): | |
| contents = read_file(test_file('browser_reporting.js')) | |
| contents = contents.replace('{{{REPORTING_URL}}}', self.SERVER_URL) | |
| create_file('browser_reporting.js', contents) | |
| def check_browser_feature(self, env_var, feature, message): | |
| skip = browser_should_skip_feature(env_var, feature) | |
| if skip == 'error': | |
| self.fail(message) | |
| elif skip: | |
| self.skipTest(message) | |
| def assert_out_queue_empty(self, who): | |
| if not self.harness_out_queue.empty(): | |
| responses = [] | |
| while not self.harness_out_queue.empty(): | |
| responses += [self.harness_out_queue.get()] | |
| raise Exception('excessive responses from %s: %s' % (who, '\n'.join(responses))) | |
| # @param extra_tries: how many more times to try this test, if it fails. browser tests have | |
| # many more causes of flakiness (in particular, they do not run | |
| # synchronously, so we have a timeout, which can be hit if the VM | |
| # we run on stalls temporarily). | |
| def run_browser(self, html_file, expected=None, message=None, timeout=None, extra_tries=None): | |
| if not has_browser(): | |
| return | |
| assert '?' not in html_file, 'URL params not supported' | |
| if extra_tries is None: | |
| extra_tries = common.EMTEST_RETRY_FLAKY if self.flaky else 0 | |
| url = html_file | |
| if self.capture_stdio: | |
| url += '?capture_stdio' | |
| if self.skip_exec: | |
| self.skipTest('skipping test execution: ' + self.skip_exec) | |
| if BrowserCore.unresponsive_tests >= BrowserCore.MAX_UNRESPONSIVE_TESTS: | |
| self.skipTest('too many unresponsive tests, skipping remaining tests') | |
| if EMTEST_RESTART_BROWSER_EVERY_N_TESTS and BrowserCore.num_tests_ran >= EMTEST_RESTART_BROWSER_EVERY_N_TESTS: | |
| logger.warning(f'[EMTEST_RESTART_BROWSER_EVERY_N_TESTS={EMTEST_RESTART_BROWSER_EVERY_N_TESTS} workaround: restarting browser]') | |
| self.browser_restart() | |
| BrowserCore.num_tests_ran += 1 | |
| self.assert_out_queue_empty('previous test') | |
| if DEBUG: | |
| print('[browser launch:', html_file, ']') | |
| assert not (message and expected), 'run_browser expects `expected` or `message`, but not both' | |
| if expected is not None: | |
| try: | |
| self.harness_in_queue.put(( | |
| 'http://localhost:%s/%s' % (self.PORT, url), | |
| self.get_dir(), | |
| )) | |
| if timeout is None: | |
| timeout = self.BROWSER_TIMEOUT | |
| try: | |
| output = self.harness_out_queue.get(block=True, timeout=timeout) | |
| except queue.Empty: | |
| BrowserCore.unresponsive_tests += 1 | |
| print(f'[unresponsive test: {self.id()} total unresponsive={str(BrowserCore.unresponsive_tests)}]') | |
| self.browser_restart() | |
| # Rather than fail the test here, let fail on the `assertContained` so | |
| # that the test can be retried via `extra_tries` | |
| output = '[no http server activity]' | |
| if output is None: | |
| # the browser harness reported an error already, and sent a None to tell | |
| # us to also fail the test | |
| self.fail('browser harness error') | |
| output = unquote(output) | |
| if output.startswith('/report_result?skipped:'): | |
| self.skipTest(unquote(output[len('/report_result?skipped:'):]).strip()) | |
| else: | |
| # verify the result, and try again if we should do so | |
| try: | |
| self.assertContained(expected, output) | |
| except self.failureException as e: | |
| if extra_tries > 0: | |
| record_flaky_test(self.id(), common.EMTEST_RETRY_FLAKY - extra_tries, common.EMTEST_RETRY_FLAKY, e) | |
| if not self.capture_stdio: | |
| print('[enabling stdio/stderr reporting]') | |
| self.capture_stdio = True | |
| return self.run_browser(html_file, expected, message, timeout, extra_tries - 1) | |
| else: | |
| raise e | |
| finally: | |
| time.sleep(0.1) # see comment about Windows above | |
| self.assert_out_queue_empty('this test') | |
| else: | |
| webbrowser.open_new(os.path.abspath(html_file)) | |
| print('A web browser window should have opened a page containing the results of a part of this test.') | |
| print('You need to manually look at the page to see that it works ok: ' + message) | |
| print('(sleeping for a bit to keep the directory alive for the web browser..)') | |
| time.sleep(5) | |
| print('(moving on..)') | |
| def compile_btest(self, filename, cflags, reporting=Reporting.FULL): | |
| # Inject support code for reporting results. This adds an include a header so testcases can | |
| # use REPORT_RESULT, and also adds a cpp file to be compiled alongside the testcase, which | |
| # contains the implementation of REPORT_RESULT (we can't just include that implementation in | |
| # the header as there may be multiple files being compiled here). | |
| if reporting != Reporting.NONE: | |
| # For basic reporting we inject JS helper functions to report result back to server. | |
| self.add_browser_reporting() | |
| cflags += ['--pre-js', 'browser_reporting.js'] | |
| if reporting == Reporting.FULL: | |
| # If C reporting (i.e. the REPORT_RESULT macro) is required we | |
| # also include report_result.c and force-include report_result.h | |
| self.run_process([EMCC, '-c', '-I' + TEST_ROOT, | |
| test_file('report_result.c')] + self.get_cflags(compile_only=True) + (['-fPIC'] if '-fPIC' in cflags else [])) | |
| cflags += ['report_result.o', '-include', test_file('report_result.h')] | |
| if EMTEST_BROWSER == 'node': | |
| cflags.append('-DEMTEST_NODE') | |
| filename = maybe_test_file(filename) | |
| self.run_process([compiler_for(filename), filename] + self.get_cflags() + cflags) | |
| # Remove the file since some tests have assertions for how many files are in | |
| # the output directory. | |
| utils.delete_file('browser_reporting.js') | |
| def btest_exit(self, filename, assert_returncode=0, *args, **kwargs): | |
| """Special case of `btest` that reports its result solely via exiting with a given result code. | |
| In this case we set EXIT_RUNTIME and we don't need to provide the | |
| REPORT_RESULT macro to the C code. | |
| """ | |
| self.set_setting('EXIT_RUNTIME') | |
| assert 'reporting' not in kwargs | |
| assert 'expected' not in kwargs | |
| kwargs['reporting'] = Reporting.JS_ONLY | |
| kwargs['expected'] = 'exit:%d' % assert_returncode | |
| return self.btest(filename, *args, **kwargs) | |
| def btest(self, filename, expected=None, | |
| post_build=None, | |
| cflags=None, | |
| timeout=None, | |
| reporting=Reporting.FULL, | |
| run_in_worker=False, | |
| output_basename='test'): | |
| assert expected, 'a btest must have an expected output' | |
| if cflags is None: | |
| cflags = [] | |
| cflags = cflags.copy() | |
| filename = find_browser_test_file(filename) | |
| if run_in_worker: | |
| outfile = output_basename + '.js' | |
| else: | |
| outfile = output_basename + '.html' | |
| cflags += ['-o', outfile] | |
| # print('cflags:', cflags) | |
| utils.delete_file(outfile) | |
| self.compile_btest(filename, cflags, reporting=reporting) | |
| self.assertExists(outfile) | |
| if post_build: | |
| post_build() | |
| if not isinstance(expected, list): | |
| expected = [expected] | |
| if EMTEST_BROWSER == 'node': | |
| output = self.run_js(f'{output_basename}.js') | |
| self.assertContained('RESULT: ' + expected[0], output) | |
| else: | |
| html_file = outfile | |
| if run_in_worker: | |
| create_file('run_worker.html', f'''\ | |
| <script> | |
| new Worker('{output_basename}.js'); | |
| </script> | |
| ''') | |
| html_file = 'run_worker.html' | |
| self.run_browser(html_file, expected=['/report_result?' + e for e in expected], timeout=timeout) | |
Xet Storage Details
- Size:
- 38.5 kB
- Xet hash:
- 36d17e3ea22f71414cde590ccef7a56d34e4f2f1d4b72613655ba2f88ae464c4
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.