Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
emscripten-core
GitHub Repository: emscripten-core/emscripten
Path: blob/main/test/browser_common.py
6174 views
1
# Copyright 2025 The Emscripten Authors. All rights reserved.
2
# Emscripten is available under two separate licenses, the MIT license and the
3
# University of Illinois/NCSA Open Source License. Both these licenses can be
4
# found in the LICENSE file.
5
6
import atexit
7
import logging
8
import os
9
import plistlib
10
import queue
11
import re
12
import shlex
13
import shutil
14
import subprocess
15
import threading
16
import time
17
import webbrowser
18
from enum import Enum
19
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
20
from urllib.parse import parse_qs, unquote, unquote_plus, urlparse
21
22
import common
23
import psutil
24
from common import (
25
TEST_ROOT,
26
RunnerCore,
27
compiler_for,
28
create_file,
29
errlog,
30
force_delete_dir,
31
maybe_test_file,
32
read_file,
33
record_flaky_test,
34
test_file,
35
)
36
37
from tools import feature_matrix, shared, utils
38
from tools.feature_matrix import UNSUPPORTED
39
from tools.shared import DEBUG, EMCC, exit_with_error
40
from tools.utils import MACOS, WINDOWS, memoize, path_from_root, read_binary
41
42
logger = logging.getLogger('common')
43
44
# User can specify an environment variable EMTEST_BROWSER to force the browser
45
# test suite to run using another browser command line than the default system
46
# browser. If only the path to the browser executable is given, the tests
47
# will run in headless mode with a temporary profile with the same options
48
# used in CI. To use a custom start command specify the executable and command
49
# line flags.
50
#
51
# Note that when specifying EMTEST_BROWSER to run tests on a Safari browser:
52
# the command line must point to the root of the app bundle, and not to the
53
# Safari executable inside the bundle. I.e. pass EMTEST_BROWSER=/Applications/Safari.app
54
# instead of EMTEST_BROWSER=/Applications/Safari.app/Contents/MacOS/Safari
55
#
56
# There are two special values that can be used here if running in an actual
57
# browser is not desired:
58
# EMTEST_BROWSER=0 : This will disable the actual running of the test and simply
59
# verify that it compiles and links.
60
# EMTEST_BROWSER=node : This will attempt to run the browser test under node.
61
# For most browser tests this does not work, but it can
62
# be useful for running pthread tests under node.
63
EMTEST_BROWSER = None
64
EMTEST_BROWSER_AUTO_CONFIG = None
65
EMTEST_HEADLESS = None
66
EMTEST_CAPTURE_STDIO = int(os.getenv('EMTEST_CAPTURE_STDIO', '0'))
67
68
# Triggers the browser to restart after every given number of tests.
69
# 0: Disabled (reuse the browser instance to run all tests. Default)
70
# 1: Restart a fresh browser instance for every browser test.
71
# 2,3,...: Restart a fresh browser instance after given number of tests have been run in it.
72
# Helps with e.g. https://bugzil.la/1992558
73
EMTEST_RESTART_BROWSER_EVERY_N_TESTS = int(os.getenv('EMTEST_RESTART_BROWSER_EVERY_N_TESTS', '0'))
74
75
DEFAULT_BROWSER_DATA_DIR = path_from_root('out/browser-profile')
76
77
browser_spawn_lock_filename = path_from_root('out/browser_spawn_lock')
78
79
80
class Reporting(Enum):
81
"""When running browser tests we normally automatically include support
82
code for reporting results back to the browser. This enum allows tests
83
to decide what type of support code they need/want.
84
"""
85
NONE = 0
86
# Include the JS helpers for reporting results
87
JS_ONLY = 1
88
# Include C/C++ reporting code (REPORT_RESULT macros) as well as JS helpers
89
FULL = 2
90
91
92
def list_processes_by_name(exe_name):
93
pids = []
94
if exe_name:
95
for proc in psutil.process_iter():
96
try:
97
pinfo = proc.as_dict(attrs=['pid', 'name', 'exe'])
98
if pinfo['exe'] and exe_name in pinfo['exe'].replace('\\', '/').split('/'):
99
pids.append(psutil.Process(pinfo['pid']))
100
except psutil.NoSuchProcess: # E.g. "process no longer exists (pid=13132)" (code raced to acquire the iterator and process it)
101
pass
102
103
return pids
104
105
106
def terminate_list_of_processes(proc_list):
107
for proc in proc_list:
108
try:
109
proc.terminate()
110
# If the browser doesn't shut down gracefully (in response to SIGTERM)
111
# after 2 seconds kill it with force (SIGKILL).
112
try:
113
proc.wait(2)
114
except (subprocess.TimeoutExpired, psutil.TimeoutExpired):
115
logger.info('Browser did not respond to `terminate`. Using `kill`')
116
proc.kill()
117
proc.wait()
118
except (psutil.NoSuchProcess, ProcessLookupError):
119
pass
120
121
122
def init(force_browser_process_termination):
123
utils.delete_file(browser_spawn_lock_filename)
124
utils.delete_file(f'{browser_spawn_lock_filename}_counter')
125
if force_browser_process_termination or os.getenv('EMTEST_FORCE_BROWSER_PROCESS_TERMINATION'):
126
config = get_browser_config()
127
128
if config and hasattr(config, 'executable_name'):
129
def terminate_all_browser_processes():
130
procs = list_processes_by_name(config.executable_name)
131
if len(procs) > 0:
132
print(f'Terminating {len(procs)} stray browser processes.')
133
terminate_list_of_processes(procs)
134
135
atexit.register(terminate_all_browser_processes)
136
terminate_all_browser_processes()
137
138
139
def find_browser_test_file(filename):
140
"""Looks for files in test/browser and then in test/
141
"""
142
if not os.path.exists(filename):
143
fullname = test_file('browser', filename)
144
if not os.path.exists(fullname):
145
fullname = test_file(filename)
146
filename = fullname
147
return filename
148
149
150
@memoize
151
def get_safari_version():
152
if not is_safari():
153
return UNSUPPORTED
154
plist_path = os.path.join(EMTEST_BROWSER.strip(), 'Contents', 'version.plist')
155
version_str = plistlib.load(open(plist_path, 'rb')).get('CFBundleShortVersionString')
156
# Split into parts (major.minor.patch)
157
parts = (version_str.split('.') + ['0', '0', '0'])[:3]
158
# Convert each part into integers, discarding any trailing string, e.g. '13a' -> 13.
159
parts = [int(re.match(r"\d+", s).group()) if re.match(r"\d+", s) else 0 for s in parts]
160
# Return version as XXYYZZ
161
return parts[0] * 10000 + parts[1] * 100 + parts[2]
162
163
164
@memoize
165
def get_firefox_version():
166
if not is_firefox():
167
return UNSUPPORTED
168
exe_path = shlex.split(EMTEST_BROWSER)[0]
169
ini_path = os.path.join(os.path.dirname(exe_path), '../Resources/platform.ini' if MACOS else 'platform.ini')
170
# Extract the first numeric part before any dot (e.g. "Milestone=102.15.1" → 102)
171
m = re.search(r"^Milestone=(.*)$", read_file(ini_path), re.MULTILINE)
172
milestone = m.group(1).strip()
173
version = int(re.match(r"(\d+)", milestone).group(1))
174
# On Nightly and Beta, e.g. 145.0a1, pretend it to still mean version 144,
175
# since it is a pre-release version
176
if any(c in milestone for c in ('a', 'b')):
177
version -= 1
178
return version
179
180
181
def browser_should_skip_feature(skip_env_var, feature):
182
# If an env. var. EMTEST_LACKS_x to skip the given test is set (to either
183
# value 0 or 1), don't bother checking if current browser supports the feature
184
# - just unconditionally run the test, or skip the test.
185
if os.getenv(skip_env_var) is not None:
186
return int(os.getenv(skip_env_var)) != 0
187
188
# If there is no Feature object associated with this capability, then we
189
# should run the test.
190
if feature is None:
191
return False
192
193
# If EMTEST_AUTOSKIP=0, also never skip.
194
if os.getenv('EMTEST_AUTOSKIP') == '0':
195
return False
196
197
# Otherwise EMTEST_AUTOSKIP=1 or EMTEST_AUTOSKIP is not set: check whether
198
# the current browser supports the test or not.
199
min_required = feature_matrix.min_browser_versions[feature]
200
not_supported = get_firefox_version() < min_required['firefox'] or get_safari_version() < min_required['safari']
201
202
# Current browser does not support the test, and EMTEST_AUTOSKIP is not set?
203
# Then error out to have end user decide what to do in this situation.
204
if not_supported and os.getenv('EMTEST_AUTOSKIP') is None:
205
return 'error'
206
207
# Report whether to skip the test based on browser support.
208
return not_supported
209
210
211
# Default flags used to run browsers in CI testing:
212
class ChromeConfig:
213
data_dir_flag = '--user-data-dir='
214
default_flags = (
215
# --no-sandbox because we are running as root and chrome requires
216
# this flag for now: https://crbug.com/638180
217
'--no-first-run -start-maximized --no-sandbox --enable-unsafe-swiftshader --use-gl=swiftshader --enable-experimental-web-platform-features --enable-features=JavaScriptSourcePhaseImports',
218
'--enable-experimental-webassembly-features --js-flags="--experimental-wasm-type-reflection --experimental-wasm-rab-integration"',
219
# The runners lack sound hardware so fallback to a dummy device (and
220
# bypass the user gesture so audio tests work without interaction)
221
'--use-fake-device-for-media-stream --autoplay-policy=no-user-gesture-required',
222
# Cache options.
223
'--disk-cache-size=1 --media-cache-size=1 --disable-application-cache',
224
# Disable various background tasks downloads (e.g. updates).
225
'--disable-background-networking',
226
# Disable native password pop-ups
227
'--password-store=basic',
228
# Send console messages to browser stderr
229
'--enable-logging=stderr',
230
)
231
headless_flags = '--headless=new --window-size=1024,768'
232
233
@staticmethod
234
def configure(data_dir):
235
"""Chrome has no special configuration step."""
236
237
@staticmethod
238
def open_url_args(url):
239
return [url]
240
241
242
class FirefoxConfig:
243
data_dir_flag = '-profile '
244
default_flags = ('-new-instance', '-wait-for-browser')
245
headless_flags = '-headless'
246
executable_name = common.exe_suffix('firefox')
247
248
@staticmethod
249
def configure(data_dir):
250
shutil.copy(test_file('firefox_user.js'), os.path.join(data_dir, 'user.js'))
251
252
@staticmethod
253
def open_url_args(url):
254
# Firefox is able to launch URLs by passing them as positional arguments,
255
# but not when the -wait-for-browser flag is in use (which we need to be
256
# able to track browser liveness). So explicitly use -url option parameter
257
# to specify the page to launch. https://bugzil.la/1996614
258
return ['-url', url]
259
260
261
class SafariConfig:
262
default_flags = ('', )
263
executable_name = 'Safari'
264
# For the macOS 'open' command, pass
265
# --new: to make a new Safari app be launched, rather than add a tab to an existing Safari process/window
266
# --fresh: do not restore old tabs (e.g. if user had old navigated windows open)
267
# --background: Open the new Safari window behind the current Terminal window, to make following the test run more pleasing (this is for convenience only)
268
# -a <exe_name>: The path to the executable to open, in this case Safari
269
launch_prefix = ('open', '--new', '--fresh', '--background', '-a')
270
271
@staticmethod
272
def configure(data_dir):
273
""" Safari has no special configuration step."""
274
275
@staticmethod
276
def open_url_args(url):
277
return [url]
278
279
280
# checks if browser testing is enabled
281
def has_browser():
282
return EMTEST_BROWSER != '0'
283
284
285
def get_browser():
286
return EMTEST_BROWSER
287
288
289
CHROMIUM_BASED_BROWSERS = ['chrom', 'edge', 'opera']
290
291
292
def is_chrome():
293
return EMTEST_BROWSER and any(pattern in EMTEST_BROWSER.lower() for pattern in CHROMIUM_BASED_BROWSERS)
294
295
296
def is_firefox():
297
return EMTEST_BROWSER and 'firefox' in EMTEST_BROWSER.lower()
298
299
300
def is_safari():
301
return EMTEST_BROWSER and 'safari' in EMTEST_BROWSER.lower()
302
303
304
def get_browser_config():
305
if is_chrome():
306
return ChromeConfig()
307
elif is_firefox():
308
return FirefoxConfig()
309
elif is_safari():
310
return SafariConfig()
311
return None
312
313
314
def configure_test_browser():
315
global EMTEST_BROWSER
316
317
if not has_browser():
318
return
319
320
if not EMTEST_BROWSER:
321
EMTEST_BROWSER = 'google-chrome'
322
323
if WINDOWS and '"' not in EMTEST_BROWSER and "'" not in EMTEST_BROWSER:
324
# On Windows env. vars canonically use backslashes as directory delimiters, e.g.
325
# set EMTEST_BROWSER=C:\Program Files\Mozilla Firefox\firefox.exe
326
# and spaces are not escaped. But make sure to also support args, e.g.
327
# set EMTEST_BROWSER="C:\Users\clb\AppData\Local\Google\Chrome SxS\Application\chrome.exe" --enable-unsafe-webgpu
328
EMTEST_BROWSER = '"' + EMTEST_BROWSER.replace("\\", "\\\\") + '"'
329
330
if EMTEST_BROWSER_AUTO_CONFIG:
331
config = get_browser_config()
332
if config:
333
EMTEST_BROWSER += ' ' + ' '.join(config.default_flags)
334
if EMTEST_HEADLESS == 1:
335
EMTEST_BROWSER += f" {config.headless_flags}"
336
337
338
# Create a server and a web page. When a test runs, we tell the server about it,
339
# which tells the web page, which then opens a window with the test. Doing
340
# it this way then allows the page to close() itself when done.
341
def make_test_server(in_queue, out_queue, port):
342
class TestServerHandler(SimpleHTTPRequestHandler):
343
# Request header handler for default do_GET() path in
344
# SimpleHTTPRequestHandler.do_GET(self) below.
345
def send_head(self):
346
if self.headers.get('Range'):
347
path = self.translate_path(self.path)
348
try:
349
fsize = os.path.getsize(path)
350
f = open(path, 'rb')
351
except OSError:
352
self.send_error(404, f'File not found {path}')
353
return None
354
self.send_response(206)
355
ctype = self.guess_type(path)
356
self.send_header('Content-Type', ctype)
357
pieces = self.headers.get('Range').split('=')[1].split('-')
358
start = int(pieces[0]) if pieces[0] != '' else 0
359
end = int(pieces[1]) if pieces[1] != '' else fsize - 1
360
end = min(fsize - 1, end)
361
length = end - start + 1
362
self.send_header('Content-Range', f'bytes {start}-{end}/{fsize}')
363
self.send_header('Content-Length', str(length))
364
self.end_headers()
365
return f
366
else:
367
return SimpleHTTPRequestHandler.send_head(self)
368
369
# Add COOP, COEP, CORP, and no-caching headers
370
def end_headers(self):
371
self.send_header('Accept-Ranges', 'bytes')
372
self.send_header('Access-Control-Allow-Origin', '*')
373
self.send_header('Cross-Origin-Opener-Policy', 'same-origin')
374
self.send_header('Cross-Origin-Embedder-Policy', 'require-corp')
375
self.send_header('Cross-Origin-Resource-Policy', 'cross-origin')
376
377
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate, private, max-age=0')
378
self.send_header('Expires', '0')
379
self.send_header('Pragma', 'no-cache')
380
self.send_header('Vary', '*') # Safari insists on caching if this header is not present in addition to the above
381
382
return SimpleHTTPRequestHandler.end_headers(self)
383
384
def do_POST(self): # noqa: DC04
385
urlinfo = urlparse(self.path)
386
query = parse_qs(urlinfo.query)
387
content_length = int(self.headers['Content-Length'])
388
post_data = self.rfile.read(content_length)
389
if urlinfo.path == '/log':
390
# Logging reported by reportStdoutToServer / reportStderrToServer.
391
#
392
# To automatically capture stderr/stdout message from browser tests, modify
393
# `captureStdoutStderr` in `test/browser_reporting.js`.
394
filename = query['file'][0]
395
print(f"[client {filename}: '{post_data.decode()}']")
396
self.send_response(200)
397
self.end_headers()
398
elif urlinfo.path == '/upload':
399
filename = query['file'][0]
400
print(f'do_POST: got file: {filename}')
401
create_file(filename, post_data, binary=True)
402
self.send_response(200)
403
self.end_headers()
404
elif urlinfo.path.startswith('/status/'):
405
code_str = urlinfo.path[len('/status/'):]
406
code = int(code_str)
407
if code in (301, 302, 303, 307, 308):
408
self.send_response(code)
409
self.send_header('Location', '/status/200')
410
self.end_headers()
411
elif code == 200:
412
self.send_response(200)
413
self.send_header('Content-type', 'text/plain')
414
self.end_headers()
415
self.wfile.write(b'OK')
416
else:
417
self.send_error(400, f'Not implemented for {code}')
418
else:
419
print(f'do_POST: unexpected POST: {urlinfo}')
420
421
def do_GET(self):
422
info = urlparse(self.path)
423
if info.path == '/run_harness':
424
if DEBUG:
425
print('[server startup]')
426
self.send_response(200)
427
self.send_header('Content-type', 'text/html')
428
self.end_headers()
429
self.wfile.write(read_binary(test_file('browser_harness.html')))
430
elif info.path.startswith('/status/'):
431
code_str = info.path[len('/status/'):]
432
code = int(code_str)
433
if code in (301, 302, 303, 307, 308):
434
# Redirect to /status/200
435
self.send_response(code)
436
self.send_header('Location', '/status/200')
437
self.end_headers()
438
elif code == 200:
439
self.send_response(200)
440
self.send_header('Content-type', 'text/plain')
441
self.end_headers()
442
self.wfile.write(b'OK')
443
else:
444
self.send_error(400, f'Not implemented for {code}')
445
elif 'report_' in self.path:
446
# for debugging, tests may encode the result and their own url (window.location) as result|url
447
if '|' in self.path:
448
path, url = self.path.split('|', 1)
449
else:
450
path = self.path
451
url = '?'
452
if DEBUG:
453
print('[server response:', path, url, ']')
454
if out_queue.empty():
455
out_queue.put(path)
456
else:
457
# a badly-behaving test may send multiple xhrs with reported results; we just care
458
# about the first (if we queued the others, they might be read as responses for
459
# later tests, or maybe the test sends more than one in a racy manner).
460
# we place 'None' in the queue here so that the outside knows something went wrong
461
# (none is not a valid value otherwise; and we need the outside to know because if we
462
# raise an error in here, it is just swallowed in python's webserver code - we want
463
# the test to actually fail, which a webserver response can't do).
464
out_queue.put(None)
465
raise Exception('browser harness error, excessive response to server - test must be fixed! "%s"' % self.path)
466
self.send_response(200)
467
self.send_header('Content-type', 'text/plain')
468
self.send_header('Connection', 'close')
469
self.end_headers()
470
self.wfile.write(b'OK')
471
472
elif info.path == '/check':
473
self.send_response(200)
474
self.send_header('Content-type', 'text/html')
475
self.end_headers()
476
if not in_queue.empty():
477
# there is a new test ready to be served
478
url, dir = in_queue.get()
479
if DEBUG:
480
print('[queue command:', url, dir, ']')
481
assert in_queue.empty(), 'should not be any blockage - one test runs at a time'
482
assert out_queue.empty(), 'the single response from the last test was read'
483
# tell the browser to load the test
484
self.wfile.write(b'COMMAND:' + url.encode('utf-8'))
485
else:
486
# the browser must keep polling
487
self.wfile.write(b'(wait)')
488
else:
489
# Use SimpleHTTPServer default file serving operation for GET.
490
if DEBUG:
491
print('[simple HTTP serving:', unquote_plus(self.path), ']')
492
if self.headers.get('Range'):
493
self.send_response(206)
494
path = self.translate_path(self.path)
495
data = read_binary(path)
496
ctype = self.guess_type(path)
497
self.send_header('Content-type', ctype)
498
pieces = self.headers.get('Range').split('=')[1].split('-')
499
start = int(pieces[0]) if pieces[0] != '' else 0
500
end = int(pieces[1]) if pieces[1] != '' else len(data) - 1
501
end = min(len(data) - 1, end)
502
length = end - start + 1
503
self.send_header('Content-Length', str(length))
504
self.send_header('Content-Range', f'bytes {start}-{end}/{len(data)}')
505
self.end_headers()
506
self.wfile.write(data[start:end + 1])
507
else:
508
SimpleHTTPRequestHandler.do_GET(self)
509
510
def log_request(code=0, size=0):
511
# don't log; too noisy
512
pass
513
514
# allows streaming compilation to work
515
SimpleHTTPRequestHandler.extensions_map['.wasm'] = 'application/wasm'
516
# Firefox browser security does not allow loading .mjs files if they
517
# do not have the correct MIME type
518
SimpleHTTPRequestHandler.extensions_map['.mjs'] = 'text/javascript'
519
520
return ThreadingHTTPServer(('localhost', port), TestServerHandler)
521
522
523
class HttpServerThread(threading.Thread):
524
"""A generic thread class to create and run an http server."""
525
def __init__(self, server):
526
super().__init__()
527
self.server = server
528
529
def stop(self):
530
"""Shuts down the server if it is running."""
531
self.server.shutdown()
532
533
def run(self):
534
"""Creates the server instance and serves forever until stop() is called."""
535
# Start the server's main loop (this blocks until shutdown() is called)
536
self.server.serve_forever()
537
538
539
# This will hold the ID for each worker process if running in parallel mode,
540
# otherwise None if running in non-parallel mode.
541
worker_id = None
542
543
544
def init_worker(counter, lock):
545
""" Initializer function for each worker.
546
It acquires a lock, gets a unique ID from the shared counter,
547
and stores it in a global variable specific to this worker process.
548
"""
549
global worker_id
550
with lock:
551
# Get the next available ID
552
worker_id = counter.value
553
# Increment the counter for the next worker
554
counter.value += 1
555
556
557
def move_browser_window(pid, x, y):
558
"""Utility function to move the top-level window owned by given process to
559
(x,y) coordinate. Used to ensure each browser window has some visible area."""
560
import win32con
561
import win32gui
562
import win32process
563
564
def enum_windows_callback(hwnd, _unused):
565
_, win_pid = win32process.GetWindowThreadProcessId(hwnd)
566
if win_pid == pid and win32gui.IsWindowVisible(hwnd):
567
# If the browser window is maximized, it won't react to MoveWindow, so
568
# un-maximize the window first to show it in windowed mode.
569
if win32gui.GetWindowPlacement(hwnd)[1] == win32con.SW_SHOWMAXIMIZED:
570
win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
571
572
# Then cascade the window, but also resize the window size to cover a
573
# smaller area of the desktop, in case the original size was full screen.
574
win32gui.MoveWindow(hwnd, x, y, 800, 600, True)
575
return True
576
577
win32gui.EnumWindows(enum_windows_callback, None)
578
579
580
def increment_suffix_number(str_with_maybe_suffix):
581
match = re.match(r"^(.*?)(?:_(\d+))?$", str_with_maybe_suffix)
582
if match:
583
base, number = match.groups()
584
if number:
585
return f'{base}_{int(number) + 1}'
586
587
return f'{str_with_maybe_suffix}_1'
588
589
590
class FileLock:
591
"""Implements a filesystem-based mutex, with an additional feature that it
592
returns an integer counter denoting how many times the lock has been locked
593
before (during the current python test run instance)"""
594
def __init__(self, path):
595
self.path = path
596
self.counter = 0
597
598
def __enter__(self):
599
# Acquire the lock
600
while True:
601
try:
602
self.fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
603
break
604
except FileExistsError:
605
time.sleep(0.1)
606
# Return the locking count number
607
try:
608
self.counter = int(open(f'{self.path}_counter').read())
609
except Exception:
610
pass
611
return self.counter
612
613
def __exit__(self, *a):
614
# Increment locking count number before releasing the lock
615
with open(f'{self.path}_counter', 'w') as f:
616
f.write(str(self.counter + 1))
617
# And release the lock
618
os.close(self.fd)
619
try:
620
os.remove(self.path)
621
except Exception:
622
pass # Another process has raced to acquire the lock, and will delete it.
623
624
625
class BrowserCore(RunnerCore):
626
# note how many tests hang / do not send an output. if many of these
627
# happen, likely something is broken and it is best to abort the test
628
# suite early, as otherwise we will wait for the timeout on every
629
# single test (hundreds of minutes)
630
MAX_UNRESPONSIVE_TESTS = 10
631
BROWSER_TIMEOUT = 60
632
633
unresponsive_tests = 0
634
num_tests_ran = 0
635
636
def __init__(self, *args, **kwargs):
637
self.capture_stdio = EMTEST_CAPTURE_STDIO
638
super().__init__(*args, **kwargs)
639
640
@classmethod
641
def browser_terminate(cls):
642
terminate_list_of_processes(cls.browser_procs)
643
644
@classmethod
645
def browser_restart(cls):
646
# Kill existing browser
647
assert has_browser()
648
logger.info('Restarting browser process')
649
cls.browser_terminate()
650
cls.browser_open(cls.HARNESS_URL)
651
BrowserCore.num_tests_ran = 0
652
653
@classmethod
654
def browser_open(cls, url):
655
assert has_browser()
656
browser_args = EMTEST_BROWSER
657
parallel_harness = worker_id is not None
658
659
config = get_browser_config()
660
if not config and EMTEST_BROWSER_AUTO_CONFIG:
661
exit_with_error(f'EMTEST_BROWSER_AUTO_CONFIG only currently works with firefox, chrome and safari. EMTEST_BROWSER was "{EMTEST_BROWSER}"')
662
663
# Prepare the browser data directory, if it uses one.
664
if EMTEST_BROWSER_AUTO_CONFIG and config and hasattr(config, 'data_dir_flag'):
665
logger.info('Using default CI configuration.')
666
browser_data_dir = DEFAULT_BROWSER_DATA_DIR
667
if parallel_harness:
668
# Running in parallel mode, give each browser its own profile dir.
669
browser_data_dir += '-' + str(worker_id)
670
671
# Delete old browser data directory.
672
if WINDOWS:
673
# If we cannot (the data dir is in use on Windows), switch to another dir.
674
while not force_delete_dir(browser_data_dir):
675
browser_data_dir = increment_suffix_number(browser_data_dir)
676
else:
677
force_delete_dir(browser_data_dir)
678
679
# Recreate the new data directory.
680
os.mkdir(browser_data_dir)
681
682
if WINDOWS:
683
# Escape directory delimiter backslashes for shlex.split.
684
browser_data_dir = browser_data_dir.replace('\\', '\\\\')
685
config.configure(browser_data_dir)
686
browser_args += f' {config.data_dir_flag}"{browser_data_dir}"'
687
688
browser_args = shlex.split(browser_args)
689
if hasattr(config, 'launch_prefix'):
690
browser_args = list(config.launch_prefix) + browser_args
691
692
logger.info('Launching browser: %s', str(browser_args))
693
694
if (WINDOWS and is_firefox()) or is_safari():
695
cls.launch_browser_harness_with_proc_snapshot_workaround(parallel_harness, config, browser_args, url)
696
else:
697
cls.browser_procs = [subprocess.Popen(browser_args + config.open_url_args(url))]
698
699
@classmethod
700
def launch_browser_harness_with_proc_snapshot_workaround(cls, parallel_harness, config, browser_args, url):
701
''' Dedicated function for launching browser harness in scenarios where
702
we need to identify the launched browser processes via a before-after
703
subprocess snapshotting delta workaround.'''
704
705
# In order for this to work, each browser needs to be launched one at a time
706
# so that we know which process belongs to which browser.
707
with FileLock(browser_spawn_lock_filename) as count:
708
# Take a snapshot before spawning the browser to find which processes
709
# existed before launching the browser.
710
if parallel_harness or is_safari():
711
procs_before = list_processes_by_name(config.executable_name)
712
713
# Browser launch
714
cls.browser_procs = [subprocess.Popen(browser_args + config.open_url_args(url))]
715
716
# Give the browser time to spawn its subprocesses. Use an increasing
717
# timeout as a crude way to account for system load.
718
if parallel_harness or is_safari():
719
time.sleep(min(5 + count * 0.3, 10))
720
procs_after = list_processes_by_name(config.executable_name)
721
722
# Take a snapshot again to find which processes exist after launching
723
# the browser. Then the newly launched browser processes are determined
724
# by the delta before->after.
725
cls.browser_procs += list(set(procs_after).difference(set(procs_before)))
726
if len(cls.browser_procs) == 0:
727
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.')
728
729
# Firefox on Windows quirk:
730
# Make sure that each browser window is visible on the desktop. Otherwise
731
# browser might decide that the tab is backgrounded, and not load a test,
732
# or it might not tick rAF()s forward, causing tests to hang.
733
if WINDOWS and parallel_harness and not EMTEST_HEADLESS:
734
# Wrap window positions on a Full HD desktop area modulo primes.
735
for proc in cls.browser_procs:
736
move_browser_window(proc.pid, (300 + count * 47) % 1901, (10 + count * 37) % 997)
737
738
@classmethod
739
def setUpClass(cls):
740
super().setUpClass()
741
cls.PORT = 8888 + (0 if worker_id is None else worker_id)
742
cls.SERVER_URL = f'http://localhost:{cls.PORT}'
743
cls.HARNESS_URL = f'{cls.SERVER_URL}/run_harness'
744
745
if not has_browser() or EMTEST_BROWSER == 'node':
746
errlog(f'[Skipping browser launch (EMTEST_BROWSER={EMTEST_BROWSER})]')
747
return
748
749
cls.harness_in_queue = queue.Queue()
750
cls.harness_out_queue = queue.Queue()
751
cls.harness_server = HttpServerThread(make_test_server(cls.harness_in_queue, cls.harness_out_queue, cls.PORT))
752
cls.harness_server.start()
753
754
errlog(f'[Browser harness server on thread {cls.harness_server.name}]')
755
cls.browser_open(cls.HARNESS_URL)
756
757
@classmethod
758
def tearDownClass(cls):
759
super().tearDownClass()
760
if not has_browser() or EMTEST_BROWSER == 'node':
761
return
762
cls.harness_server.stop()
763
cls.harness_server.join()
764
cls.browser_terminate()
765
766
if WINDOWS:
767
# On Windows, shutil.rmtree() in tearDown() raises this exception if we do not wait a bit:
768
# WindowsError: [Error 32] The process cannot access the file because it is being used by another process.
769
time.sleep(0.1)
770
771
def is_browser_test(self):
772
return True
773
774
def add_browser_reporting(self):
775
contents = read_file(test_file('browser_reporting.js'))
776
contents = contents.replace('{{{REPORTING_URL}}}', self.SERVER_URL)
777
create_file('browser_reporting.js', contents)
778
779
def check_browser_feature(self, env_var, feature, message):
780
skip = browser_should_skip_feature(env_var, feature)
781
if skip == 'error':
782
self.fail(message)
783
elif skip:
784
self.skipTest(message)
785
786
def assert_out_queue_empty(self, who):
787
if not self.harness_out_queue.empty():
788
responses = []
789
while not self.harness_out_queue.empty():
790
responses += [self.harness_out_queue.get()]
791
raise Exception('excessive responses from %s: %s' % (who, '\n'.join(responses)))
792
793
# @param extra_tries: how many more times to try this test, if it fails. browser tests have
794
# many more causes of flakiness (in particular, they do not run
795
# synchronously, so we have a timeout, which can be hit if the VM
796
# we run on stalls temporarily).
797
def run_browser(self, html_file, expected=None, message=None, timeout=None, extra_tries=None):
798
if not has_browser():
799
return
800
assert '?' not in html_file, 'URL params not supported'
801
if extra_tries is None:
802
extra_tries = common.EMTEST_RETRY_FLAKY if self.flaky else 0
803
url = html_file
804
if self.capture_stdio:
805
url += '?capture_stdio'
806
if self.skip_exec:
807
self.skipTest('skipping test execution: ' + self.skip_exec)
808
if BrowserCore.unresponsive_tests >= BrowserCore.MAX_UNRESPONSIVE_TESTS:
809
self.skipTest('too many unresponsive tests, skipping remaining tests')
810
811
if EMTEST_RESTART_BROWSER_EVERY_N_TESTS and BrowserCore.num_tests_ran >= EMTEST_RESTART_BROWSER_EVERY_N_TESTS:
812
logger.warning(f'[EMTEST_RESTART_BROWSER_EVERY_N_TESTS={EMTEST_RESTART_BROWSER_EVERY_N_TESTS} workaround: restarting browser]')
813
self.browser_restart()
814
BrowserCore.num_tests_ran += 1
815
816
self.assert_out_queue_empty('previous test')
817
if DEBUG:
818
print('[browser launch:', html_file, ']')
819
assert not (message and expected), 'run_browser expects `expected` or `message`, but not both'
820
821
if expected is not None:
822
try:
823
self.harness_in_queue.put((
824
'http://localhost:%s/%s' % (self.PORT, url),
825
self.get_dir(),
826
))
827
if timeout is None:
828
timeout = self.BROWSER_TIMEOUT
829
try:
830
output = self.harness_out_queue.get(block=True, timeout=timeout)
831
except queue.Empty:
832
BrowserCore.unresponsive_tests += 1
833
print(f'[unresponsive test: {self.id()} total unresponsive={str(BrowserCore.unresponsive_tests)}]')
834
self.browser_restart()
835
# Rather than fail the test here, let fail on the `assertContained` so
836
# that the test can be retried via `extra_tries`
837
output = '[no http server activity]'
838
if output is None:
839
# the browser harness reported an error already, and sent a None to tell
840
# us to also fail the test
841
self.fail('browser harness error')
842
output = unquote(output)
843
if output.startswith('/report_result?skipped:'):
844
self.skipTest(unquote(output[len('/report_result?skipped:'):]).strip())
845
else:
846
# verify the result, and try again if we should do so
847
try:
848
self.assertContained(expected, output)
849
except self.failureException as e:
850
if extra_tries > 0:
851
record_flaky_test(self.id(), common.EMTEST_RETRY_FLAKY - extra_tries, common.EMTEST_RETRY_FLAKY, e)
852
if not self.capture_stdio:
853
print('[enabling stdio/stderr reporting]')
854
self.capture_stdio = True
855
return self.run_browser(html_file, expected, message, timeout, extra_tries - 1)
856
else:
857
raise e
858
finally:
859
time.sleep(0.1) # see comment about Windows above
860
self.assert_out_queue_empty('this test')
861
else:
862
webbrowser.open_new(os.path.abspath(html_file))
863
print('A web browser window should have opened a page containing the results of a part of this test.')
864
print('You need to manually look at the page to see that it works ok: ' + message)
865
print('(sleeping for a bit to keep the directory alive for the web browser..)')
866
time.sleep(5)
867
print('(moving on..)')
868
869
def compile_btest(self, filename, cflags, reporting=Reporting.FULL):
870
# Inject support code for reporting results. This adds an include a header so testcases can
871
# use REPORT_RESULT, and also adds a cpp file to be compiled alongside the testcase, which
872
# contains the implementation of REPORT_RESULT (we can't just include that implementation in
873
# the header as there may be multiple files being compiled here).
874
if reporting != Reporting.NONE:
875
# For basic reporting we inject JS helper functions to report result back to server.
876
self.add_browser_reporting()
877
cflags += ['--pre-js', 'browser_reporting.js']
878
if reporting == Reporting.FULL:
879
# If C reporting (i.e. the REPORT_RESULT macro) is required we
880
# also include report_result.c and force-include report_result.h
881
self.run_process([EMCC, '-c', '-I' + TEST_ROOT,
882
test_file('report_result.c')] + self.get_cflags(compile_only=True) + (['-fPIC'] if '-fPIC' in cflags else []))
883
cflags += ['report_result.o', '-include', test_file('report_result.h')]
884
if EMTEST_BROWSER == 'node':
885
cflags.append('-DEMTEST_NODE')
886
filename = maybe_test_file(filename)
887
self.run_process([compiler_for(filename), filename] + self.get_cflags() + cflags)
888
# Remove the file since some tests have assertions for how many files are in
889
# the output directory.
890
utils.delete_file('browser_reporting.js')
891
892
def btest_exit(self, filename, assert_returncode=0, *args, **kwargs):
893
"""Special case of `btest` that reports its result solely via exiting
894
with a given result code.
895
896
In this case we set EXIT_RUNTIME and we don't need to provide the
897
REPORT_RESULT macro to the C code.
898
"""
899
self.set_setting('EXIT_RUNTIME')
900
assert 'reporting' not in kwargs
901
assert 'expected' not in kwargs
902
kwargs['reporting'] = Reporting.JS_ONLY
903
kwargs['expected'] = 'exit:%d' % assert_returncode
904
return self.btest(filename, *args, **kwargs)
905
906
def btest(self, filename, expected=None,
907
post_build=None,
908
cflags=None,
909
timeout=None,
910
reporting=Reporting.FULL,
911
run_in_worker=False,
912
output_basename='test'):
913
assert expected, 'a btest must have an expected output'
914
if cflags is None:
915
cflags = []
916
cflags = cflags.copy()
917
filename = find_browser_test_file(filename)
918
if run_in_worker:
919
outfile = output_basename + '.js'
920
else:
921
outfile = output_basename + '.html'
922
cflags += ['-o', outfile]
923
# print('cflags:', cflags)
924
utils.delete_file(outfile)
925
self.compile_btest(filename, cflags, reporting=reporting)
926
self.assertExists(outfile)
927
if post_build:
928
post_build()
929
if not isinstance(expected, list):
930
expected = [expected]
931
if EMTEST_BROWSER == 'node':
932
nodejs = self.require_node()
933
self.node_args += shared.node_pthread_flags(nodejs)
934
output = self.run_js(f'{output_basename}.js')
935
self.assertContained('RESULT: ' + expected[0], output)
936
else:
937
html_file = outfile
938
if run_in_worker:
939
create_file('run_worker.html', f'''\
940
<script>
941
new Worker('{output_basename}.js');
942
</script>
943
''')
944
html_file = 'run_worker.html'
945
self.run_browser(html_file, expected=['/report_result?' + e for e in expected], timeout=timeout)
946
947