From 7301fc7362349d8380844e8a99715744f19f9890 Mon Sep 17 00:00:00 2001
From: Sam Clegg <sbc@chromium.org>
Date: Wed, 5 Nov 2025 10:37:20 -0800
Subject: [PATCH] [test] Split browser-specific code into browser_common.py.
 NFC

common.py was getting pretty huge and about 900 lines of it was
browser-test specific stuff.
---
 test/browser_common.py         | 915 +++++++++++++++++++++++++++++++++
 test/common.py                 | 896 +-------------------------------
 test/parallel_testsuite.py     |   3 +-
 test/runner.py                 |  13 +-
 test/test_browser.py           |  37 +-
 test/test_interactive.py       |   3 +-
 test/test_posixtest_browser.py |   2 +-
 test/test_sockets.py           |   3 +-
 8 files changed, 957 insertions(+), 915 deletions(-)
 create mode 100644 test/browser_common.py

diff --git a/test/browser_common.py b/test/browser_common.py
new file mode 100644
index 0000000000000..ef9f8d362c8a6
--- /dev/null
+++ b/test/browser_common.py
@@ -0,0 +1,915 @@
+# 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 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,
+  create_file,
+  errlog,
+  force_delete_dir,
+  maybe_test_file,
+  read_file,
+  record_flaky_test,
+  test_file,
+)
+
+from tools import feature_matrix, shared, utils
+from tools.feature_matrix import UNSUPPORTED
+from tools.shared import DEBUG, EMCC, exit_with_error
+from tools.utils import 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):
+  """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 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
+
+
+@memoize
+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(read_binary(plist_path)).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]
+
+
+@memoize
+def get_firefox_version():
+  if not is_firefox():
+    return UNSUPPORTED
+  exe_path = shlex.split(EMTEST_BROWSER)[0]
+  ini_path = os.path.join(os.path.dirname(exe_path), "platform.ini")
+  # 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 --experimental-wasm-rab-integration"',
+    # 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'
+
+  @staticmethod
+  def configure(data_dir):
+    """Chrome has no special configuration step."""
+
+  @staticmethod
+  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 = utils.exe_suffix('firefox')
+
+  @staticmethod
+  def configure(data_dir):
+    shutil.copy(test_file('firefox_user.js'), os.path.join(data_dir, 'user.js'))
+
+  @staticmethod
+  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')
+
+  @staticmethod
+  def configure(data_dir):
+    """ Safari has no special configuration step."""
+
+  @staticmethod
+  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 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):
+    """Creates the server instance and serves 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 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, with an additional feature that it
+  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(open(f'{self.path}_counter').read())
+    except Exception:
+      pass
+    return self.counter
+
+  def __exit__(self, *a):
+    # Increment locking count number before releasing the lock
+    with open(f'{self.path}_counter', 'w') as f:
+      f.write(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)
+
+  @classmethod
+  def browser_terminate(cls):
+    terminate_list_of_processes(cls.browser_procs)
+
+  @classmethod
+  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
+
+  @classmethod
+  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))]
+
+  @classmethod
+  def launch_browser_harness_with_proc_snapshot_workaround(cls, parallel_harness, config, browser_args, url):
+    ''' 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)
+
+  @classmethod
+  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)
+
+  @classmethod
+  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 funtions 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,
+            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)
+    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':
+      nodejs = self.require_node()
+      self.node_args += shared.node_pthread_flags(nodejs)
+      output = self.run_js('test.js')
+      self.assertContained('RESULT: ' + expected[0], output)
+    else:
+      self.run_browser(outfile, expected=['/report_result?' + e for e in expected], timeout=timeout)
diff --git a/test/common.py b/test/common.py
index bf06d625c6704..68f7c903a83d3 100644
--- a/test/common.py
+++ b/test/common.py
@@ -10,8 +10,6 @@
 import json
 import logging
 import os
-import plistlib
-import queue
 import re
 import shlex
 import shutil
@@ -21,31 +19,23 @@
 import sys
 import tempfile
 import textwrap
-import threading
-import time
-import webbrowser
-from enum import Enum
 from functools import wraps
-from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
 from pathlib import Path
 from subprocess import PIPE, STDOUT
 from typing import Dict, Tuple
-from urllib.parse import parse_qs, unquote, unquote_plus, urlparse
 
 import clang_native
 import jsrun
 import line_endings
-import psutil
 from retryable_unittest import RetryableTestCase
 
 from tools import building, config, feature_matrix, shared, utils
-from tools.feature_matrix import UNSUPPORTED, Feature, min_browser_versions
+from tools.feature_matrix import Feature
 from tools.settings import COMPILE_TIME_SETTINGS
 from tools.shared import DEBUG, EMCC, EMXX, get_canonical_temp_dir
 from tools.utils import (
   WINDOWS,
   exit_with_error,
-  memoize,
   path_from_root,
   read_binary,
   read_file,
@@ -54,28 +44,11 @@
 
 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
+# If we are drawing a parallel swimlane graph of test output, we need to use a temp
+# file to track which tests were flaky so they can be graphed in orange color to
+# visually stand out.
+flaky_tests_log_filename = os.path.join(path_from_root('out/flaky_tests.txt'))
+
 EMTEST_DETECT_TEMPFILE_LEAKS = None
 EMTEST_SAVE_DIR = None
 # generally js engines are equivalent, testing 1 is enough. set this
@@ -94,154 +67,9 @@
 # 2: Log stdout and stderr configure/make. Print out subprocess commands that were executed.
 # 3: Log stdout and stderr, and pass VERBOSE=1 to CMake/configure/make steps.
 EMTEST_BUILD_VERBOSE = int(os.getenv('EMTEST_BUILD_VERBOSE', '0'))
-EMTEST_CAPTURE_STDIO = int(os.getenv('EMTEST_CAPTURE_STDIO', '0'))
 if 'EM_BUILD_VERBOSE' in os.environ:
   exit_with_error('EM_BUILD_VERBOSE has been renamed to EMTEST_BUILD_VERBOSE')
 
-# 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'))
-
-# If we are drawing a parallel swimlane graph of test output, we need to use a temp
-# file to track which tests were flaky so they can be graphed in orange color to
-# visually stand out.
-flaky_tests_log_filename = path_from_root('out/flaky_tests.txt')
-browser_spawn_lock_filename = path_from_root('out/browser_spawn_lock')
-
-
-@memoize
-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(read_binary(plist_path)).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]
-
-
-@memoize
-def get_firefox_version():
-  if not is_firefox():
-    return UNSUPPORTED
-  exe_path = shlex.split(EMTEST_BROWSER)[0]
-  ini_path = os.path.join(os.path.dirname(exe_path), "platform.ini")
-  # 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 = 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 --experimental-wasm-rab-integration"',
-    # 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'
-
-  @staticmethod
-  def configure(data_dir):
-    """Chrome has no special configuration step."""
-
-  @staticmethod
-  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 = utils.exe_suffix('firefox')
-
-  @staticmethod
-  def configure(data_dir):
-    shutil.copy(test_file('firefox_user.js'), os.path.join(data_dir, 'user.js'))
-
-  @staticmethod
-  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')
-
-  @staticmethod
-  def configure(data_dir):
-    """ Safari has no special configuration step."""
-
-  @staticmethod
-  def open_url_args(url):
-    return [url]
-
-
 # Special value for passing to assert_returncode which means we expect that program
 # to fail with non-zero return code, but we don't care about specifically which one.
 NON_ZERO = -1
@@ -250,8 +78,6 @@ def open_url_args(url):
 LAST_TEST = path_from_root('out/last_test.txt')
 PREVIOUS_TEST_RUN_RESULTS_FILE = path_from_root('out/previous_test_run_results.json')
 
-DEFAULT_BROWSER_DATA_DIR = path_from_root('out/browser-profile')
-
 WEBIDL_BINDER = utils.bat_suffix(path_from_root('tools/webidl_binder'))
 
 EMBUILDER = utils.bat_suffix(path_from_root('embuilder'))
@@ -302,36 +128,6 @@ def copytree(src, dest):
   shutil.copytree(src, dest, dirs_exist_ok=True)
 
 
-# checks if browser testing is enabled
-def has_browser():
-  return EMTEST_BROWSER != '0'
-
-
-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 compiler_for(filename, force_c=False):
   if utils.suffix(filename) in ('.cc', '.cxx', '.cpp') and not force_c:
     return EMXX
@@ -468,17 +264,6 @@ def force_delete_contents(dirname):
   utils.delete_contents(dirname)
 
 
-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_output_suffix(args):
   if any(a in args for a in ('-sEXPORT_ES6', '-sWASM_ESM_INTEGRATION', '-sMODULARIZE=instance')):
     return '.mjs'
@@ -681,11 +466,7 @@ def require_wasm_legacy_eh(self):
     self.set_setting('WASM_LEGACY_EXCEPTIONS')
 
     if self.is_browser_test():
-      skip = browser_should_skip_feature('EMTEST_SKIP_WASM_LEGACY_EH', Feature.WASM_LEGACY_EXCEPTIONS)
-      if skip == 'error':
-        self.fail('test requires Wasm Legacy EH')
-      elif skip:
-        self.skipTest('test requires Wasm Legacy EH')
+      self.check_browser_feature('EMTEST_SKIP_WASM_LEGACY_EH', Feature.WASM_LEGACY_EXCEPTIONS, 'test requires Wasm Legacy EH')
       return
 
     if self.try_require_node_version(17):
@@ -705,11 +486,7 @@ def require_wasm_eh(self):
     self.set_setting('WASM_LEGACY_EXCEPTIONS', 0)
 
     if self.is_browser_test():
-      skip = browser_should_skip_feature('EMTEST_SKIP_WASM_EH', Feature.WASM_EXCEPTIONS)
-      if skip == 'error':
-        self.fail('test requires Wasm EH')
-      elif skip:
-        self.skipTest('test requires Wasm EH')
+      self.check_browser_feature('EMTEST_SKIP_WASM_EH', Feature.WASM_EXCEPTIONS, 'test requires Wasm EH')
       return
 
     if self.try_require_node_version(22):
@@ -1716,663 +1493,6 @@ def get_zlib_library(self, cmake, cflags=None):
     return rtn
 
 
-# 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):
-    """Creates the server instance and serves forever until stop() is called."""
-    # Start the server's main loop (this blocks until shutdown() is called)
-    self.server.serve_forever()
-
-
-class Reporting(Enum):
-  """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
-
-
-# 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 configure_test_browser():
-  global EMTEST_BROWSER
-
-  if not has_browser():
-    return
-
-  if not EMTEST_BROWSER:
-    EMTEST_BROWSER = 'google-chrome'
-
-  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}"
-
-
-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
-
-
-class FileLock:
-  """Implements a filesystem-based mutex, with an additional feature that it
-  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(open(f'{self.path}_counter').read())
-    except Exception:
-      pass
-    return self.counter
-
-  def __exit__(self, *a):
-    # Increment locking count number before releasing the lock
-    with open(f'{self.path}_counter', 'w') as f:
-      f.write(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.
-
-
-def move_browser_window(pid, x, y):
-  """Utility function to move the top-level window 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 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)
-
-  @classmethod
-  def browser_terminate(cls):
-    terminate_list_of_processes(cls.browser_procs)
-
-  @classmethod
-  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
-
-  @classmethod
-  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))]
-
-  @classmethod
-  def launch_browser_harness_with_proc_snapshot_workaround(cls, parallel_harness, config, browser_args, url):
-    ''' 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)
-
-  @classmethod
-  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)
-
-  @classmethod
-  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 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 = 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(), EMTEST_RETRY_FLAKY - extra_tries, 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 funtions 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,
-            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)
-    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':
-      nodejs = self.require_node()
-      self.node_args += shared.node_pthread_flags(nodejs)
-      output = self.run_js('test.js')
-      self.assertContained('RESULT: ' + expected[0], output)
-    else:
-      self.run_browser(outfile, expected=['/report_result?' + e for e in expected], timeout=timeout)
-
-
 ###################################################################################################
 
 
diff --git a/test/parallel_testsuite.py b/test/parallel_testsuite.py
index 389f2db2eed0c..302ee8943df5f 100644
--- a/test/parallel_testsuite.py
+++ b/test/parallel_testsuite.py
@@ -11,6 +11,7 @@
 import time
 import unittest
 
+import browser_common
 import common
 from common import errlog
 
@@ -124,7 +125,7 @@ def run(self, result):
       worker_id_lock = manager.Lock()
       with multiprocessing.Pool(
         processes=use_cores,
-        initializer=common.init_worker,
+        initializer=browser_common.init_worker,
         initargs=(worker_id_counter, worker_id_lock),
       ) as pool:
         if python_multiprocessing_structures_are_buggy():
diff --git a/test/runner.py b/test/runner.py
index 5bf359959ccb1..4b9dff78364e0 100755
--- a/test/runner.py
+++ b/test/runner.py
@@ -37,6 +37,7 @@
 __rootpath__ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 sys.path.insert(0, __rootpath__)
 
+import browser_common
 import common
 import jsrun
 import parallel_testsuite
@@ -509,9 +510,9 @@ def parse_args():
 
 
 def configure():
-  common.EMTEST_BROWSER = os.getenv('EMTEST_BROWSER')
-  common.EMTEST_BROWSER_AUTO_CONFIG = os.getenv('EMTEST_BROWSER_AUTO_CONFIG')
-  common.EMTEST_HEADLESS = int(os.getenv('EMTEST_HEADLESS', '0'))
+  browser_common.EMTEST_BROWSER = os.getenv('EMTEST_BROWSER')
+  browser_common.EMTEST_BROWSER_AUTO_CONFIG = os.getenv('EMTEST_BROWSER_AUTO_CONFIG')
+  browser_common.EMTEST_HEADLESS = int(os.getenv('EMTEST_HEADLESS', '0'))
   common.EMTEST_DETECT_TEMPFILE_LEAKS = int(os.getenv('EMTEST_DETECT_TEMPFILE_LEAKS', '0'))
   common.EMTEST_ALL_ENGINES = int(os.getenv('EMTEST_ALL_ENGINES', '0'))
   common.EMTEST_SKIP_SLOW = int(os.getenv('EMTEST_SKIP_SLOW', '0'))
@@ -526,7 +527,7 @@ def configure():
   assert 'PARALLEL_SUITE_EMCC_CORES' not in os.environ, 'use EMTEST_CORES rather than PARALLEL_SUITE_EMCC_CORES'
   parallel_testsuite.NUM_CORES = os.environ.get('EMTEST_CORES') or os.environ.get('EMCC_CORES')
 
-  common.configure_test_browser()
+  browser_common.configure_test_browser()
 
 
 def cleanup_emscripten_temp():
@@ -582,8 +583,8 @@ def set_env(name, option_value):
   # Remove any old test files before starting the run
   cleanup_emscripten_temp()
   utils.delete_file(common.flaky_tests_log_filename)
-  utils.delete_file(common.browser_spawn_lock_filename)
-  utils.delete_file(f'{common.browser_spawn_lock_filename}_counter')
+  utils.delete_file(browser_common.browser_spawn_lock_filename)
+  utils.delete_file(f'{browser_common.browser_spawn_lock_filename}_counter')
   if options.force_browser_process_termination or os.getenv('EMTEST_FORCE_BROWSER_PROCESS_TERMINATION'):
     config = common.get_browser_config()
 
diff --git a/test/test_browser.py b/test/test_browser.py
index 1dc877d65bcb7..1686fd3cd9c2e 100644
--- a/test/test_browser.py
+++ b/test/test_browser.py
@@ -22,24 +22,27 @@
 from urllib.request import urlopen
 
 import common
-from common import (
+from browser_common import (
   CHROMIUM_BASED_BROWSERS,
-  EMRUN,
-  WEBIDL_BINDER,
   BrowserCore,
   HttpServerThread,
   Reporting,
-  RunnerCore,
   browser_should_skip_feature,
-  copytree,
-  create_file,
-  ensure_dir,
   find_browser_test_file,
+  get_browser,
   get_safari_version,
   has_browser,
   is_chrome,
   is_firefox,
   is_safari,
+)
+from common import (
+  EMRUN,
+  WEBIDL_BINDER,
+  RunnerCore,
+  copytree,
+  create_file,
+  ensure_dir,
   path_from_root,
   read_file,
   test_file,
@@ -177,7 +180,7 @@ def shell_with_script(shell_file, output_file, replacement):
 
 
 def is_swiftshader(_):
-  return is_chrome() and '--use-gl=swiftshader' in common.EMTEST_BROWSER
+  return is_chrome() and '--use-gl=swiftshader' in get_browser()
 
 
 no_swiftshader = skip_if_simple('not compatible with swiftshader', is_swiftshader)
@@ -232,7 +235,7 @@ def decorator(f):
     @wraps(f)
     def decorated(self, *args, **kwargs):
       if should_skip == 'error':
-        raise Exception(f'This test requires a browser that supports {feature.name} but your browser {common.EMTEST_BROWSER} does not support this. Run with {skip_env_var}=1 or EMTEST_AUTOSKIP=1 to skip this test automatically.')
+        raise Exception(f'This test requires a browser that supports {feature.name} but your browser {get_browser()} does not support this. Run with {skip_env_var}=1 or EMTEST_AUTOSKIP=1 to skip this test automatically.')
       elif should_skip:
         self.skip_exec = message
       f(self, *args, **kwargs)
@@ -265,7 +268,7 @@ class browser(BrowserCore):
   def setUpClass(cls):
     super().setUpClass()
     cls.browser_timeout = 60
-    if common.EMTEST_BROWSER != 'node':
+    if get_browser() != 'node':
       print()
       print('Running the browser tests. Make sure the browser allows popups from localhost.')
       print()
@@ -275,7 +278,7 @@ def proxy_to_worker(self):
 
   def require_jspi(self):
     if not is_chrome():
-      self.skipTest(f'Current browser ({common.EMTEST_BROWSER}) does not support JSPI. Only chromium-based browsers ({CHROMIUM_BASED_BROWSERS}) support JSPI today.')
+      self.skipTest(f'Current browser ({get_browser()}) does not support JSPI. Only chromium-based browsers ({CHROMIUM_BASED_BROWSERS}) support JSPI today.')
     super().require_jspi()
 
   def post_manual_reftest(self):
@@ -3351,7 +3354,7 @@ def test_cocos2d_hello(self):
   })
   def test_async(self, opt, args):
     if is_jspi(args) and not is_chrome():
-      self.skipTest(f'Current browser ({common.EMTEST_BROWSER}) does not support JSPI. Only chromium-based browsers ({CHROMIUM_BASED_BROWSERS}) support JSPI today.')
+      self.skipTest(f'Current browser ({get_browser()}) does not support JSPI. Only chromium-based browsers ({CHROMIUM_BASED_BROWSERS}) support JSPI today.')
 
     self.btest_exit('test_async.c', cflags=[opt, '-g2'] + args)
 
@@ -5045,7 +5048,7 @@ def test_embind_with_pthreads(self):
   })
   def test_embind(self, args):
     if is_jspi(args) and not is_chrome():
-      self.skipTest(f'Current browser ({common.EMTEST_BROWSER}) does not support JSPI. Only chromium-based browsers ({CHROMIUM_BASED_BROWSERS}) support JSPI today.')
+      self.skipTest(f'Current browser ({get_browser()}) does not support JSPI. Only chromium-based browsers ({CHROMIUM_BASED_BROWSERS}) support JSPI today.')
     if is_jspi(args) and self.is_wasm64():
       self.skipTest('_emval_await fails')
 
@@ -5728,9 +5731,9 @@ def test_no_browser(self):
     self.run_process([EMCC, test_file('test_emrun.c'), '--emrun', '-o', 'hello_world.html'])
     proc = subprocess.Popen([EMRUN, '--no-browser', '.', '--port=3333'], stdout=PIPE)
     try:
-      if common.EMTEST_BROWSER:
+      if get_browser():
         print('Starting browser')
-        browser_cmd = shlex.split(common.EMTEST_BROWSER)
+        browser_cmd = shlex.split(get_browser())
         browser = subprocess.Popen(browser_cmd + ['http://localhost:3333/hello_world.html'])
         try:
           while True:
@@ -5769,11 +5772,11 @@ def test_emrun(self):
                  '--log-stdout', self.in_dir('stdout.txt'),
                  '--log-stderr', self.in_dir('stderr.txt')]
 
-    if common.EMTEST_BROWSER is not None:
+    if get_browser() is not None:
       # If EMTEST_BROWSER carried command line arguments to pass to the browser,
       # (e.g. "firefox -profile /path/to/foo") those can't be passed via emrun,
       # so strip them out.
-      browser_cmd = shlex.split(common.EMTEST_BROWSER)
+      browser_cmd = shlex.split(get_browser())
       browser_path = browser_cmd[0]
       args_base += ['--browser', browser_path]
       if len(browser_cmd) > 1:
diff --git a/test/test_interactive.py b/test/test_interactive.py
index 1a6e6ac483751..0979f9ecb1183 100644
--- a/test/test_interactive.py
+++ b/test/test_interactive.py
@@ -10,7 +10,8 @@
 if __name__ == '__main__':
   raise Exception('do not run this file directly; do something like: test/runner.py interactive')
 
-from common import BrowserCore, create_file, test_file
+from browser_common import BrowserCore
+from common import create_file, test_file
 from decorators import also_with_minimal_runtime, parameterized
 
 from tools.utils import WINDOWS
diff --git a/test/test_posixtest_browser.py b/test/test_posixtest_browser.py
index 36be0c1792949..c2b999d5be887 100644
--- a/test/test_posixtest_browser.py
+++ b/test/test_posixtest_browser.py
@@ -7,7 +7,7 @@
 parallel.
 """
 
-from common import BrowserCore
+from browser_common import BrowserCore
 
 
 class posixtest_browser(BrowserCore):
diff --git a/test/test_sockets.py b/test/test_sockets.py
index 874e9fb926fb8..1a21d5866bdca 100644
--- a/test/test_sockets.py
+++ b/test/test_sockets.py
@@ -17,7 +17,8 @@
 
 import clang_native
 import common
-from common import NON_ZERO, PYTHON, BrowserCore, create_file, read_file
+from browser_common import BrowserCore
+from common import NON_ZERO, PYTHON, create_file, read_file
 from decorators import (
   crossplatform,
   no_windows,
