import argparse
import glob
import http.client
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import traceback
from datetime import datetime
GREEN = '\033[92m'
RED = '\033[91m'
YELLOW = '\033[93m'
GRAY = '\033[90m'
RESET = '\033[0m'
CHECKMARK = '✓'
CROSSMARK = '✗'
CLEAR_LINE = '\033[2K\r'
class TestRunner:
def __init__(self):
self.tests = []
self.tmpdir = None
self.webcentral_proc = None
self.port = None
self.log_positions = {} self.use_firejail = True
self.current_test_domain = None self.current_test_domains = set()
def find_free_port(self):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('', 0))
s.listen(1)
port = s.getsockname()[1]
return port
def setup(self):
cwd = os.path.dirname(os.path.abspath(__file__))
self.tmpdir = os.path.join(cwd, '.maca-test')
if os.path.exists(self.tmpdir):
shutil.rmtree(self.tmpdir)
os.makedirs(self.tmpdir)
print(f"Test directory: {self.tmpdir}")
os.makedirs(f"{self.tmpdir}/stdout/_webcentral_data/log")
os.makedirs(f"{self.tmpdir}/stderr/_webcentral_data/log")
pre = f"{self.tmpdir}/pre-existing-directory.test/public"
os.makedirs(pre)
with open(pre+"/index.html", 'w') as f:
f.write("I was already here")
self.port = self.find_free_port()
print(f"Test port: {self.port}")
stdout_log = f"{self.tmpdir}/stdout/_webcentral_data/log/current"
stderr_log = f"{self.tmpdir}/stderr/_webcentral_data/log/current"
stdout_f = open(stdout_log, 'w')
stderr_f = open(stderr_log, 'w')
cmd = ['target/debug/webcentral',
'--projects', self.tmpdir,
'--http', str(self.port),
'--https', '0',
'--data-dir', self.tmpdir,
'--firejail', "true" if self.use_firejail else "false"]
print(" ".join(cmd))
self.webcentral_proc = subprocess.Popen(
cmd,
stdout=stdout_f,
stderr=stderr_f,
cwd=os.path.dirname(os.path.abspath(__file__))
)
self.stdout_f = stdout_f
self.stderr_f = stderr_f
self.log_positions['stdout'] = 0
self.log_positions['stderr'] = 0
time.sleep(0.5)
if self.webcentral_proc.poll() is not None:
self.show_all_new_logs()
raise Exception("Webcentral process failed to start")
print(f"webcentral started (PID: {self.webcentral_proc.pid})")
print()
def teardown(self):
if self.webcentral_proc:
self.webcentral_proc.terminate()
try:
self.webcentral_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self.webcentral_proc.kill()
self.webcentral_proc.wait()
if hasattr(self, 'stdout_f'):
self.stdout_f.close()
if hasattr(self, 'stderr_f'):
self.stderr_f.close()
if self.tmpdir and os.path.exists(self.tmpdir):
shutil.rmtree(self.tmpdir)
def get_log_path(self, project):
return f"{self.tmpdir}/{project}/_webcentral_data/log/current"
def get_log_content(self, project, from_pos=0):
log_path = self.get_log_path(project)
if not os.path.exists(log_path):
return ""
with open(log_path, 'r') as f:
f.seek(from_pos)
return f.read()
def get_current_log_position(self, project):
log_path = self.get_log_path(project)
if not os.path.exists(log_path):
return 0
return os.path.getsize(log_path)
def write_file(self, path, content, domain=None):
if domain is None:
domain = self.current_test_domain
if domain:
self.current_test_domains.add(domain)
if not path.startswith(domain):
path = f"{domain}/{path}"
full_path = os.path.join(self.tmpdir, path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, 'w') as f:
f.write(content)
def mark_log_read(self, project=None):
if project is None:
project = self.current_test_domain
self.log_positions[project] = self.get_current_log_position(project)
def mark_all_logs_read(self):
for domain in self.current_test_domains:
self.log_positions[domain] = self.get_current_log_position(domain)
for special in ['stdout', 'stderr']:
self.log_positions[special] = self.get_current_log_position(special)
def show_all_new_logs(self):
from time import sleep
sleep(0.5)
DARK_GRAY = '\033[38;5;240m'
has_output = False
for domain in self.current_test_domains:
log_path = self.get_log_path(domain)
if not os.path.exists(log_path):
continue
full_content = self.get_log_content(domain, 0)
if not full_content.strip():
continue
if not has_output:
print(f"\n{YELLOW}=== Log output ==={RESET}")
has_output = True
read_pos = self.log_positions.get(domain, 0)
read_content = full_content[:read_pos] if read_pos > 0 else ""
new_content = full_content[read_pos:]
print(f"\n{YELLOW}--- {domain} ---{RESET}")
if read_content:
print(f"{DARK_GRAY}{read_content}{RESET}", end='')
if new_content:
print(new_content, end='')
if not full_content.endswith('\n'):
print()
for special in ['stdout', 'stderr']:
log_path = self.get_log_path(special)
if not os.path.exists(log_path):
continue
full_content = self.get_log_content(special, 0)
if not full_content.strip():
continue
if not has_output:
print(f"\n{YELLOW}=== Log output ==={RESET}")
has_output = True
read_pos = self.log_positions.get(special, 0)
read_content = full_content[:read_pos] if read_pos > 0 else ""
new_content = full_content[read_pos:]
print(f"\n{YELLOW}--- {special} ---{RESET}")
if read_content:
print(f"{DARK_GRAY}{read_content}{RESET}", end='')
if new_content:
print(new_content, end='')
if not full_content.endswith('\n'):
print()
def assert_log(self, text_or_project, text=None, count=1):
if text is None:
project = self.current_test_domain
text = text_or_project
else:
project = text_or_project
start_pos = self.log_positions.get(project, 0)
content = self.get_log_content(project, start_pos)
actual_count = content.count(text)
if actual_count != count:
raise AssertionError(
f"Expected '{text}' to appear {count} time(s) in {project} logs, "
f"but found {actual_count} time(s)."
)
def await_log(self, text_or_project, text=None, timeout=2):
if text is None:
project = self.current_test_domain
text = text_or_project
else:
project = text_or_project
print(f"{CLEAR_LINE}{GRAY}→ Waiting for log: {text[:50]}...{RESET}", end='', flush=True)
start_pos = self.log_positions.get(project, 0)
start_time = time.time()
while time.time() - start_time < timeout:
content = self.get_log_content(project, start_pos)
if text in content:
return
time.sleep(0.05)
print() content = self.get_log_content(project, start_pos)
raise TimeoutError(
f"Timeout waiting for '{text}' in {project} logs after {timeout}s."
)
def assert_http(self, path, check_body=None, check_code=200, check_header=None, method='GET', data=None, timeout=5, host=None):
if host is None:
host = self.current_test_domain
print(f"{CLEAR_LINE}{GRAY}→ HTTP {method} {host}{path}{RESET}", end='', flush=True)
conn = http.client.HTTPConnection('localhost', self.port, timeout=timeout)
headers = {'Host': host}
try:
conn.request(method, path, body=data, headers=headers)
response = conn.getresponse()
body = response.read().decode('utf-8')
if check_code is not None and response.status != check_code:
raise AssertionError(
f"Expected status {check_code}, got {response.status}\n"
f"Body: {body}"
)
if check_body is not None and check_body not in body:
raise AssertionError(
f"Expected body to contain '{check_body}', got:\n{body}"
)
if check_header is not None:
header_name, expected_value = check_header
actual_value = response.getheader(header_name)
if actual_value != expected_value:
raise AssertionError(
f"Expected header '{header_name}' to be '{expected_value}', got '{actual_value}'"
)
return body
finally:
print(CLEAR_LINE, end='', flush=True)
conn.close()
def register_test(self, func):
self.tests.append(func)
return func
def run(self, test_names=None):
tests_to_run = self.tests
if test_names:
normalized_names = []
for name in test_names:
if not name.startswith('test_'):
normalized_names.append(f'test_{name}')
else:
normalized_names.append(name)
tests_to_run = [t for t in self.tests if t.__name__ in normalized_names]
found_names = {t.__name__ for t in tests_to_run}
for name in normalized_names:
if name not in found_names:
print(f"{RED}Error: Test '{name}' not found{RESET}")
sys.exit(1)
self.setup()
failed = False
try:
for test_func in tests_to_run:
test_name = test_func.__name__
domain = test_name.replace('test_', '').replace('_', '-') + '.test'
self.current_test_domain = domain
self.current_test_domains = {domain}
try:
self.mark_all_logs_read()
domain_dir = os.path.join(self.tmpdir, domain)
os.makedirs(domain_dir, exist_ok=True)
self.await_log('stdout', f'Domain {domain} added')
test_func(self)
print(f"{CLEAR_LINE}{GREEN}{CHECKMARK}{RESET} {test_name}")
except Exception as e:
print(f"{CLEAR_LINE}{RED}{CROSSMARK}{RESET} {test_name}")
print(f"{RED}Error:{RESET} {e}")
tb = traceback.extract_tb(e.__traceback__)
for frame in tb:
if frame.name == test_func.__name__:
print(f"{RED}At:{RESET} {frame.filename}:{frame.lineno}")
break
self.show_all_new_logs()
print(f"\nTest directory preserved at: {self.tmpdir}")
failed = True
break
if not failed:
print(f"\n{GREEN}All {len(tests_to_run)} tests passed!{RESET}")
finally:
if self.webcentral_proc:
self.webcentral_proc.terminate()
try:
self.webcentral_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self.webcentral_proc.kill()
self.webcentral_proc.wait()
if hasattr(self, 'stdout_f'):
self.stdout_f.close()
if hasattr(self, 'stderr_f'):
self.stderr_f.close()
if not failed and self.tmpdir and os.path.exists(self.tmpdir):
shutil.rmtree(self.tmpdir)
if failed:
sys.exit(1)
runner = TestRunner()
def test(func):
runner.register_test(func)
return func
@test
def test_pre_existing_project(t):
t.assert_http('/', check_body='I was already here', host='pre-existing-directory.test')
@test
def test_static_file_serving(t):
t.write_file('public/index.html', '<h1>Hello World</h1>')
t.assert_http('/', check_body='Hello World')
@test
def test_static_file_nested(t):
t.write_file('public/css/style.css', 'body { color: red; }')
t.assert_http('/css/style.css', check_body='color: red', check_header=('Content-Type', 'text/css'))
t.write_file('public/app.js', 'console.log("hello");')
t.assert_http('/app.js', check_body='hello', check_header=('Content-Type', 'text/javascript'))
@test
def test_simple_application(t):
t.write_file('webcentral.ini', 'command=python3 -u -m http.server $PORT')
t.write_file('index.html', '<h1>App Server</h1>')
t.assert_http('/', check_body='App Server')
t.assert_log('Ready on port', count=1)
@test
def test_application_file_change_reload(t):
t.write_file('webcentral.ini', 'command=python3 -u -m http.server $PORT')
t.write_file('index.html', '<h1>Version 1</h1>')
t.assert_http('/', check_body='Version 1')
t.assert_log('Ready on port', count=1)
t.mark_log_read()
t.write_file('index.html', '<h1>Version 2</h1>')
t.await_log('Stopping due to file changes')
t.assert_http('/', check_body='Version 2')
t.assert_log('Ready on port', count=1)
@test
def test_config_change_reload(t):
t.write_file('webcentral.ini', 'command=python3 -u -m http.server $PORT')
t.write_file('page.html', '<h1>Test Page</h1>')
t.assert_http('/page.html', check_body='Test Page')
t.assert_log('Ready on port', count=1)
t.mark_log_read()
t.write_file('webcentral.ini',
'command=python3 -u -m http.server $PORT\n\n[reload]\ntimeout=300')
t.await_log('Stopping due to file changes')
t.assert_http('/page.html', check_body='Test Page')
t.assert_log('Ready on port', count=1)
@test
def test_slow_starting_application(t):
t.write_file('server.py', '''
import time
import sys
import os
time.sleep(1)
print("Starting server...", flush=True)
import http.server
import socketserver
PORT = int(os.environ.get('PORT'))
Handler = http.server.SimpleHTTPRequestHandler
with socketserver.TCPServer(("", PORT), Handler) as httpd:
print(f"Server started on port {PORT}", flush=True)
httpd.serve_forever()
''')
t.write_file('webcentral.ini', 'command=python3 -u server.py')
t.write_file('data.txt', 'Slow Server Data')
t.assert_http('/data.txt', check_body='Slow Server Data')
t.assert_log('Ready on port', count=1)
@test
def test_graceful_shutdown_delay(t):
t.write_file('server.py', '''
import signal
import sys
import time
import os
import http.server
import socketserver
from threading import Thread
shutdown_flag = False
def handle_term(signum, frame):
global shutdown_flag
print("Received TERM signal, delaying shutdown...", flush=True)
shutdown_flag = True
time.sleep(1)
print("Shutdown delay complete", flush=True)
sys.exit(0)
signal.signal(signal.SIGTERM, handle_term)
PORT = int(os.environ.get('PORT'))
Handler = http.server.SimpleHTTPRequestHandler
with socketserver.TCPServer(("", PORT), Handler) as httpd:
print(f"Server running on port {PORT}", flush=True)
httpd.serve_forever()
''')
t.write_file('webcentral.ini', 'command=python3 -u server.py')
t.write_file('index.html', '<h1>Shutdown Test</h1>')
t.assert_http('/', check_body='Shutdown Test')
t.assert_log('Server running', count=1)
t.mark_log_read()
t.write_file('index.html', '<h1>Shutdown Test v2</h1>')
t.await_log('Received TERM signal', timeout=5)
t.await_log('Shutdown delay complete')
t.assert_http('/', check_body='Shutdown Test v2')
t.assert_log('Server running', count=1)
@test
def test_redirect_configuration(t):
t.write_file('webcentral.ini', 'redirect=https://example.org/')
t.assert_http('/', check_code=301)
@test
def test_multiple_projects_isolation(t):
t.write_file('public/index.html', '<h1>Project 1</h1>', domain='project1.test')
t.write_file('public/index.html', '<h1>Project 2</h1>', domain='project2.test')
t.write_file('webcentral.ini',
'command=python3 -u -m http.server $PORT', domain='project3.test')
t.write_file('index.html', '<h1>Project 3</h1>', domain='project3.test')
t.await_log('stdout', 'Domain project1.test added')
t.await_log('stdout', 'Domain project2.test added')
t.await_log('stdout', 'Domain project3.test added')
t.assert_http('/', check_body='Project 1', host='project1.test')
t.assert_http('/', check_body='Project 2', host='project2.test')
t.assert_http('/', check_body='Project 3', host='project3.test')
t.assert_log('project3.test', 'Ready on port', count=1)
@test
def test_404_on_missing_file(t):
t.write_file('public/exists.html', '<h1>Exists</h1>')
t.assert_http('/exists.html', check_body='Exists')
t.assert_http('/missing.html', check_code=404)
@test
def test_application_stops_on_inactivity(t):
t.write_file('webcentral.ini',
'command=python3 -u -m http.server $PORT\n\n[reload]\ntimeout=1')
t.write_file('index.html', '<h1>Timeout Test</h1>')
t.assert_http('/', check_body='Timeout Test')
t.assert_log('Ready on port', count=1)
t.mark_log_read()
t.await_log('Stopping due to inactivity', timeout=3)
@test
def test_application_restarts_after_timeout(t):
t.write_file('webcentral.ini',
'command=python3 -u -m http.server $PORT\n\n[reload]\ntimeout=1')
t.write_file('index.html', '<h1>Restart Test</h1>')
t.assert_http('/', check_body='Restart Test')
t.assert_log('Ready on port', count=1)
t.mark_log_read()
t.await_log('Stopping due to inactivity', timeout=3)
t.mark_log_read()
t.assert_http('/', check_body='Restart Test')
t.await_log('Ready on port')
@test
def test_no_command_serves_static_only(t):
t.write_file('public/page.html', '<h1>Static Only</h1>')
t.assert_http('/page.html', check_body='Static Only')
@test
def test_env_variables_in_config(t):
t.write_file('server.py', '''
import os
import http.server
import socketserver
class MyHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
value = os.environ.get('TEST_VAR', 'not set')
self.wfile.write(f"TEST_VAR={value}".encode())
PORT = int(os.environ.get('PORT'))
print(f"Starting on port {PORT}", flush=True)
with socketserver.TCPServer(("", PORT), MyHandler) as httpd:
httpd.serve_forever()
''')
t.write_file('webcentral.ini',
'command=python3 -u server.py\n\n[environment]\nTEST_VAR=hello_world')
t.assert_http('/', check_body='TEST_VAR=hello_world')
t.assert_log('Ready on port', count=1)
@test
def test_post_request(t):
t.write_file('server.py', '''
import os
import http.server
import socketserver
class MyHandler(http.server.SimpleHTTPRequestHandler):
def do_POST(self):
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length).decode('utf-8')
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
self.wfile.write(f"Received: {post_data}".encode())
PORT = int(os.environ.get('PORT'))
print(f"POST server on port {PORT}", flush=True)
with socketserver.TCPServer(("", PORT), MyHandler) as httpd:
httpd.serve_forever()
''')
t.write_file('webcentral.ini', 'command=python3 -u server.py')
t.assert_http('/', method='POST', data='test_data', check_body='Received: test_data')
t.assert_log('Ready on port', count=1)
@test
def test_multiple_file_changes_single_reload(t):
t.write_file('webcentral.ini', 'command=python3 -u -m http.server $PORT')
t.write_file('file1.html', 'v1')
t.write_file('file2.html', 'v1')
t.assert_http('/file1.html', check_body='v1')
t.await_log('Ready on port')
t.mark_log_read()
t.write_file('file1.html', 'v2')
t.write_file('file2.html', 'v2')
t.write_file('file3.html', 'v2')
t.await_log('Stopping due to file changes')
t.assert_log('Stopping due to file changes', count=1)
t.assert_http('/file1.html', check_body='v2')
t.await_log('Ready on port')
t.mark_log_read()
t.write_file('file1.html', 'v3')
t.await_log('Stopping due to file changes')
t.assert_http('/file1.html', check_body='v3')
@test
def test_subdirectory_files(t):
t.write_file('public/assets/js/app.js', 'console.log("test");')
t.write_file('public/assets/css/style.css', 'body {}')
t.assert_http('/assets/js/app.js', check_body='console.log')
t.assert_http('/assets/css/style.css', check_body='body {}')
@test
def test_index_html_default(t):
t.write_file('public/index.html', '<h1>Index Page</h1>')
t.assert_http('/', check_body='Index Page')
@test
def test_application_with_custom_port(t):
t.write_file('webcentral.ini', 'command=python3 -u -m http.server $PORT')
t.write_file('test.html', '<h1>Custom Port</h1>')
t.assert_http('/test.html', check_body='Custom Port')
t.assert_log('Ready on port', count=1)
@test
def test_request_to_nonexistent_domain(t):
t.assert_http('/', check_code=404, host='nonexistent.domain')
@test
def test_concurrent_requests_same_project(t):
t.write_file('public/data.txt', 'Concurrent Data')
t.assert_http('/data.txt', check_body='Concurrent Data')
t.assert_http('/data.txt', check_body='Concurrent Data')
t.assert_http('/data.txt', check_body='Concurrent Data')
@test
def test_webcentral_starts_successfully(t):
t.mark_log_read('stderr')
time.sleep(0.2)
stderr_content = t.get_log_content('stderr', 0)
if 'fatal' in stderr_content.lower() or 'panic' in stderr_content.lower():
raise AssertionError(f"Fatal error in stderr: {stderr_content}")
@test
def test_config_unknown_key_in_root(t):
t.write_file('webcentral.ini',
'command=python3 -u -m http.server $PORT\nunknown_key=value')
t.write_file('index.html', '<h1>Test</h1>')
t.assert_http('/', check_body='Test')
t.assert_log("Unexpected key 'unknown_key'", count=1)
@test
def test_config_unknown_section(t):
t.write_file('webcentral.ini',
'command=python3 -u -m http.server $PORT\n\n[invalid_section]\nkey=value')
t.write_file('index.html', '<h1>Test</h1>')
t.assert_http('/', check_body='Test')
t.assert_log("Unexpected key 'invalid_section.key'", count=1)
@test
def test_config_unknown_key_in_docker(t):
t.write_file('webcentral.ini', '[docker]\nbase=alpine\ninvalid_docker_key=value')
t.write_file('public/index.html', '<h1>Test</h1>')
try:
t.assert_http('/', check_body='Test')
except:
pass
t.await_log("Unexpected key 'docker.invalid_docker_key'", timeout=2)
@test
def test_http2_support(t):
try:
result = subprocess.run(['curl', '--version'], capture_output=True, text=True)
if 'HTTP2' not in result.stdout:
print(f"{YELLOW}Skipped: curl does not support HTTP/2{RESET}")
return
except FileNotFoundError:
print(f"{YELLOW}Skipped: curl not found{RESET}")
return
t.write_file('public/index.html', '<h1>HTTP/2 Test</h1>')
t.assert_http('/', check_body='HTTP/2 Test')
cmd = ['curl', '--http2-prior-knowledge', '-v', f'http://localhost:{t.port}/', '-H', f'Host: {t.current_test_domain}']
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
if 'HTTP/2' in result.stderr or 'HTTP/2' in result.stdout:
pass elif 'Using HTTP2' in result.stderr:
pass else:
if 'HTTP/1.1' in result.stderr:
raise AssertionError("Server downgraded to HTTP/1.1, expected HTTP/2")
pass
except subprocess.TimeoutExpired:
raise AssertionError("curl timed out")
@test
def test_config_unknown_key_in_reload(t):
t.write_file('webcentral.ini',
'command=python3 -u -m http.server $PORT\n\n[reload]\ntimeout=60\nbad_key=123')
t.write_file('index.html', '<h1>Test</h1>')
t.assert_http('/', check_body='Test')
t.assert_log("Unexpected key 'reload.bad_key'", count=1)
t.assert_log('Ready on port', count=1)
@test
def test_procfile_unsupported_type(t):
t.write_file('Procfile', 'web: python3 -u -m http.server $PORT\nclock: python3 clock.py')
t.write_file('index.html', '<h1>Procfile Test</h1>')
t.assert_http('/', check_body='Procfile Test')
t.assert_log("Procfile process type 'clock' is not supported", count=1)
t.assert_log('Ready on port', count=1)
@test
def test_procfile_web_only(t):
t.write_file('Procfile', 'web: python3 -u -m http.server $PORT')
t.write_file('index.html', '<h1>Procfile Web</h1>')
t.assert_http('/', check_body='Procfile Web')
t.assert_log('Ready on port', count=1)
@test
def test_procfile_with_worker(t):
t.write_file('worker.py', '''
import time
import os
print("I am a starting test worker...", flush=True)
time.sleep(0.5)
with open("worker_output.txt", "w") as f:
f.write("Worker was here")
print("I am a finished little worker", flush=True)
time.sleep(100) # Keep running
''')
t.write_file('Procfile', 'web: python3 -u -m http.server $PORT\nworker: python3 -u worker.py')
t.write_file('index.html', '<h1>Procfile with Worker</h1>')
t.assert_http('/', check_body='Procfile with Worker')
t.assert_log('Ready on port', count=1)
t.assert_log('Starting 1 worker(s)', count=1)
t.await_log('I am a starting test worker...')
t.await_log('I am a finished little worker')
time.sleep(0.2)
worker_output = os.path.join(t.tmpdir, f'{t.current_test_domain}/worker_output.txt')
if not os.path.exists(worker_output):
raise AssertionError("Worker output file was not created")
@test
def test_procfile_multiple_workers(t):
t.write_file('worker1.py', '''
import time
print("Worker 1 starting", flush=True)
time.sleep(100)
''')
t.write_file('worker2.py', '''
import time
print("Worker 2 starting", flush=True)
time.sleep(100)
''')
t.write_file('Procfile',
'web: python3 -u -m http.server $PORT\n'
'worker: python3 -u worker1.py\n'
'urgentworker: python3 -u worker2.py')
t.write_file('index.html', '<h1>Multiple Workers</h1>')
t.assert_http('/', check_body='Multiple Workers')
t.assert_log('Ready on port', count=1)
t.assert_log('Starting 2 worker(s)', count=1)
t.await_log('Worker 1 starting')
t.await_log('Worker 2 starting')
@test
def test_ini_single_worker(t):
t.write_file('worker.py', '''
import time
print("INI Worker running", flush=True)
time.sleep(100)
''')
t.write_file('webcentral.ini',
'command=python3 -u -m http.server $PORT\n'
'worker=python3 -u worker.py')
t.write_file('index.html', '<h1>INI Worker</h1>')
t.assert_http('/', check_body='INI Worker')
t.assert_log('Ready on port', count=1)
t.assert_log('Starting 1 worker(s)', count=1)
t.await_log('INI Worker running')
@test
def test_ini_multiple_named_workers(t):
t.write_file('email_worker.py', '''
import time
print("Email worker active", flush=True)
time.sleep(100)
''')
t.write_file('task_worker.py', '''
import time
print("Task worker active", flush=True)
time.sleep(100)
''')
t.write_file('webcentral.ini',
'command=python3 -u -m http.server $PORT\n'
'worker:email=python3 -u email_worker.py\n'
'worker:tasks=python3 -u task_worker.py')
t.write_file('index.html', '<h1>Multiple Named Workers</h1>')
t.assert_http('/', check_body='Multiple Named Workers')
t.assert_log('Ready on port', count=1)
t.assert_log('Starting 2 worker(s)', count=1)
t.await_log('Email worker active')
t.await_log('Task worker active')
@test
def test_workers_restart_on_file_change(t):
t.write_file('worker.py', '''
import time
print("Worker v1", flush=True)
time.sleep(100)
''')
t.write_file('webcentral.ini',
'command=python3 -u -m http.server $PORT\n'
'worker=python3 -u worker.py')
t.write_file('index.html', '<h1>Version 1</h1>')
t.assert_http('/', check_body='Version 1')
t.assert_log('Ready on port', count=1)
t.await_log('Worker v1')
t.mark_log_read()
t.write_file('worker.py', '''
import time
print("Worker v2", flush=True)
time.sleep(100)
''')
t.await_log('Stopping due to file changes')
t.assert_http('/', check_body='Version 1')
t.assert_log('Ready on port', count=1)
t.await_log('Worker v2')
@test
def test_workers_stop_on_inactivity(t):
t.write_file('worker.py', '''
import time
print("Worker running", flush=True)
time.sleep(100)
''')
t.write_file('webcentral.ini',
'command=python3 -u -m http.server $PORT\n'
'worker=python3 -u worker.py\n\n'
'[reload]\ntimeout=1')
t.write_file('index.html', '<h1>Worker Timeout</h1>')
t.assert_http('/', check_body='Worker Timeout')
t.assert_log('Ready on port', count=1)
t.await_log('Worker running')
t.mark_log_read()
t.await_log('Stopping due to inactivity', timeout=3)
@test
def test_broken_ini_syntax_error(t):
t.write_file('webcentral.ini', 'asdfasdf\n!!@@##\ngarbage\n')
t.write_file('public/index.html', '<h1>Static Content</h1>')
t.assert_http('/', check_body='Static Content')
t.assert_log('Invalid syntax in webcentral.ini at line 1: asdfasdf', count=1)
t.assert_log('Invalid syntax in webcentral.ini at line 2: !!@@##', count=1)
t.assert_log('Invalid syntax in webcentral.ini at line 3: garbage', count=1)
@test
def test_edit_broken_ini_triggers_reload(t):
t.write_file('webcentral.ini', 'garbage nonsense\n!!!')
t.write_file('public/index.html', '<h1>Version 1</h1>')
t.assert_http('/', check_body='Version 1')
t.mark_log_read()
t.write_file('webcentral.ini', 'different garbage\n###')
t.await_log('Stopping due to file changes', timeout=2)
t.assert_http('/', check_body='Version 1')
@test
def test_ini_disappearing_app_becomes_static(t):
t.write_file('webcentral.ini', 'command=python3 -u -m http.server $PORT')
t.write_file('index.html', '<h1>App Content</h1>')
t.write_file('public/index.html', '<h1>Static Content</h1>')
t.assert_http('/', check_body='App Content')
t.await_log('Ready on port')
t.mark_log_read()
os.remove(os.path.join(t.tmpdir, f'{t.current_test_domain}/webcentral.ini'))
t.await_log('Stopped app', timeout=10)
t.assert_http('/', check_body='Static Content')
t.await_log('Static file server', timeout=4)
@test
def test_ini_appearing_static_becomes_app(t):
t.write_file('public/index.html', '<h1>Static Only</h1>')
t.write_file('index.html', '<h1>App Will Serve This</h1>')
t.assert_http('/', check_body='Static Only')
t.mark_log_read()
t.write_file('webcentral.ini', 'command=python3 -u -m http.server $PORT')
t.await_log('Stopping due to file changes', timeout=2)
t.assert_http('/', check_body='App Will Serve This')
t.assert_log('Ready on port', count=1)
@test
def test_ini_broken_to_valid(t):
t.write_file('webcentral.ini', 'broken syntax!!!\n###')
t.write_file('public/index.html', '<h1>Static</h1>')
t.write_file('index.html', '<h1>App Content</h1>')
t.assert_http('/', check_body='Static')
t.assert_log('Invalid syntax in webcentral.ini', count=1)
t.mark_log_read()
t.write_file('webcentral.ini', 'command=python3 -u -m http.server $PORT')
t.await_log('Stopping due to file changes', timeout=2)
t.assert_http('/', check_body='App Content')
t.assert_log('Ready on port', count=1)
@test
def test_ini_valid_to_broken(t):
t.write_file('webcentral.ini', 'command=python3 -u -m http.server $PORT')
t.write_file('index.html', '<h1>App</h1>')
t.write_file('public/index.html', '<h1>Static</h1>')
t.assert_http('/', check_body='App')
t.assert_log('Ready on port', count=1)
t.mark_log_read()
t.write_file('webcentral.ini', 'invalid!!!\ngarbage')
t.await_log('Stopping due to file changes', timeout=2)
t.assert_http('/', check_body='Static')
t.assert_log('Invalid syntax in webcentral.ini', count=2)
@test
def test_command_changing(t):
t.write_file('webcentral.ini', 'command=python3 -u -m http.server $PORT')
t.write_file('index.html', '<h1>HTTP Server</h1>')
t.assert_http('/', check_body='HTTP Server')
t.assert_log('Ready on port', count=1)
t.mark_log_read()
t.write_file('server.py', '''
import os
import http.server
import socketserver
class CustomHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(b"<h1>Custom Server</h1>")
PORT = int(os.environ.get('PORT'))
with socketserver.TCPServer(("", PORT), CustomHandler) as httpd:
print(f"Custom server on {PORT}", flush=True)
httpd.serve_forever()
''')
t.write_file('webcentral.ini', 'command=python3 -u server.py')
t.await_log('Stopping due to file changes', timeout=2)
t.assert_http('/', check_body='Custom Server')
t.await_log('Custom server on')
@test
def test_environment_variables_changing(t):
t.write_file('server.py', '''
import os
import http.server
import socketserver
class EnvHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
env_value = os.environ.get('MY_VAR', 'not set')
self.wfile.write(f"MY_VAR={env_value}".encode())
PORT = int(os.environ.get('PORT'))
with socketserver.TCPServer(("", PORT), EnvHandler) as httpd:
httpd.serve_forever()
''')
t.write_file('webcentral.ini', 'command=python3 -u server.py\n\n[environment]\nMY_VAR=value1')
t.assert_http('/', check_body='MY_VAR=value1')
t.assert_log('Ready on port', count=1)
t.mark_log_read()
t.write_file('webcentral.ini', 'command=python3 -u server.py\n\n[environment]\nMY_VAR=value2')
t.await_log('Stopping due to file changes', timeout=2)
t.assert_http('/', check_body='MY_VAR=value2')
@test
def test_workers_added_via_config_change(t):
t.write_file('webcentral.ini', 'command=python3 -u -m http.server $PORT')
t.write_file('index.html', '<h1>Test</h1>')
t.write_file('worker.py', '''
import time
print("New worker started", flush=True)
time.sleep(100)
''')
t.assert_http('/', check_body='Test')
t.assert_log('Ready on port', count=1)
t.mark_log_read()
t.write_file('webcentral.ini',
'command=python3 -u -m http.server $PORT\n'
'worker=python3 -u worker.py')
t.await_log('Stopping due to file changes', timeout=2)
t.assert_http('/', check_body='Test')
t.assert_log('Starting 1 worker(s)', count=1)
t.await_log('New worker started')
@test
def test_workers_removed_via_config_change(t):
t.write_file('worker.py', '''
import time
print("Worker running", flush=True)
time.sleep(100)
''')
t.write_file('webcentral.ini',
'command=python3 -u -m http.server $PORT\n'
'worker=python3 -u worker.py')
t.write_file('index.html', '<h1>Test</h1>')
t.assert_http('/', check_body='Test')
t.await_log('Worker running')
t.mark_log_read()
t.write_file('webcentral.ini', 'command=python3 -u -m http.server $PORT')
t.await_log('Stopping due to file changes', timeout=2)
t.mark_log_read()
t.assert_http('/', check_body='Test')
new_logs = t.get_log_content(t.current_test_domain, t.log_positions[t.current_test_domain])
if 'Starting 1 worker(s)' in new_logs:
raise AssertionError("Workers should not be started after removal from config")
@test
def test_redirect_changes_to_app(t):
t.write_file('webcentral.ini',
'redirect=https://example.com/')
t.write_file('index.html', '<h1>App Content</h1>')
t.assert_http('/', check_code=301)
t.mark_log_read()
t.write_file('webcentral.ini',
'command=python3 -u -m http.server $PORT')
t.await_log('Stopping due to file changes', timeout=2)
t.assert_http('/', check_body='App Content')
t.assert_log('Ready on port', count=1)
@test
def test_app_changes_to_redirect(t):
t.write_file('webcentral.ini',
'command=python3 -u -m http.server $PORT')
t.write_file('index.html', '<h1>App</h1>')
t.assert_http('/', check_body='App')
t.assert_log('Ready on port', count=1)
t.mark_log_read()
t.write_file('webcentral.ini',
'redirect=https://example.org/')
t.await_log('Stopping due to file changes', timeout=2)
t.assert_http('/', check_code=301)
@test
def test_static_ignores_non_config_file_changes(t):
t.write_file('public/index.html', '<h1>Static</h1>')
t.assert_http('/', check_body='Static')
t.mark_log_read()
t.write_file('random.txt', 'some content')
t.assert_http('/', check_body='Static')
t.assert_log('Stopping due to file changes', count=0)
t.write_file('webcentral.ini', 'command=echo test')
t.await_log('Stopping due to file changes', timeout=2)
@test
def test_app_respects_include_patterns(t):
t.write_file('webcentral.ini', '''
command=python3 -u -m http.server $PORT
[reload]
include[]=*.py
include[]=webcentral.ini
''')
t.write_file('index.html', '<h1>Original</h1>')
t.assert_http('/', check_body='Original')
t.await_log('Ready on port')
t.mark_log_read()
t.write_file('data.txt', 'new data')
t.assert_http('/', check_body='Original')
t.assert_log('Stopping due to file changes', count=0)
t.write_file('script.py', 'print("test")')
t.await_log('Stopping due to file changes', timeout=2)
@test
def test_app_respects_exclude_patterns(t):
t.write_file('webcentral.ini', '''
command=python3 -u -m http.server $PORT
[reload]
exclude[]=*.tmp
exclude[]=temp/*
''')
t.write_file('index.html', '<h1>Test</h1>')
t.assert_http('/', check_body='Test')
t.assert_log('Ready on port', count=1)
t.mark_log_read()
t.write_file('temp.tmp', 'temporary')
t.assert_http('/', check_body='Test')
t.assert_log('Stopping due to file changes', count=0)
t.write_file('temp/file.txt', 'data')
t.assert_http('/', check_body='Test')
t.assert_log('Stopping due to file changes', count=0)
t.write_file('data.json', '{}')
t.await_log('Stopping due to file changes', timeout=2)
@test
def test_rooted_path_pattern(t):
t.write_file('webcentral.ini', '''
command=python3 -u -m http.server $PORT
[reload]
include[]=**/*
exclude[]=subdir/package.json
''')
t.write_file('package.json', '{"version": "1.0.0"}')
t.write_file('subdir/package.json', '{"version": "2.0.0"}')
t.assert_http('/')
t.assert_log('Ready on port', count=1)
t.mark_log_read()
t.write_file('subdir/package.json', '{"version": "2.0.1"}')
t.assert_http('/')
t.assert_log('Stopping due to file changes', count=0)
t.write_file('package.json', '{"version": "1.0.1"}')
t.await_log('Stopping due to file changes', timeout=2)
@test
def test_matches_pattern_logic(t):
t.write_file('webcentral.ini', '''
command=python3 -u -m http.server $PORT
[reload]
# With only includes and no excludes, verify src/package.json triggers reload
include[]=src/package.json
''')
t.write_file('index.html', '<h1>V1</h1>')
t.write_file('src/package.json', '{"version": "1.0.0"}')
t.write_file('src/other.js', 'console.log("v1")')
t.assert_http('/', check_body='V1')
t.assert_log('Ready on port', count=1)
t.mark_log_read()
t.write_file('src/other.js', 'console.log("v2")')
t.assert_http('/', check_body='V1')
t.assert_log('Stopping due to file changes', count=0)
t.write_file('src/package.json', '{"version": "1.0.1"}')
t.await_log('Stopping due to file changes', timeout=2)
@test
def test_www_redirect_to_apex(t):
t.write_file('public/index.html', '<h1>Apex Domain</h1>', domain='example.com')
conn = http.client.HTTPConnection('localhost', t.port)
conn.request('GET', '/', headers={'Host': 'www.example.com'})
response = conn.getresponse()
if response.status != 301:
raise AssertionError(f"Expected redirect (301), got {response.status}")
location = response.getheader('Location')
if not location or 'example.com' not in location:
raise AssertionError(f"Expected redirect to example.com, got {location}")
conn.close()
@test
def test_apex_redirect_to_www(t):
t.write_file('public/index.html', '<h1>WWW Domain</h1>', domain='www.example.net')
conn = http.client.HTTPConnection('localhost', t.port)
conn.request('GET', '/', headers={'Host': 'example.net'})
response = conn.getresponse()
if response.status != 301:
raise AssertionError(f"Expected redirect (301), got {response.status}")
location = response.getheader('Location')
if not location or 'www.example.net' not in location:
raise AssertionError(f"Expected redirect to www.example.net, got {location}")
conn.close()
@test
def test_static_mime_types(t):
t.write_file('public/style.css', 'body { color: red; }')
t.write_file('public/script.js', 'console.log("test");')
t.write_file('public/data.json', '{"key": "value"}')
t.write_file('public/page.html', '<h1>HTML</h1>')
t.write_file('public/image.svg', '<svg></svg>')
t.write_file('public/doc.txt', 'Plain text')
conn = http.client.HTTPConnection('localhost', t.port)
conn.request('GET', '/style.css', headers={'Host': t.current_test_domain})
response = conn.getresponse()
body = response.read()
content_type = response.getheader('Content-Type')
conn.close()
conn = http.client.HTTPConnection('localhost', t.port)
conn.request('GET', '/script.js', headers={'Host': t.current_test_domain})
response = conn.getresponse()
body = response.read()
conn.close()
t.assert_http('/data.json', check_body='key')
t.assert_http('/page.html', check_body='HTML')
@test
def test_websocket_proxy(t):
import wstool
import shutil
import os
import time
project_dir = os.path.join(t.tmpdir, t.current_test_domain)
os.makedirs(project_dir, exist_ok=True)
wstool_src = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'wstool.py')
shutil.copy(wstool_src, os.path.join(project_dir, 'wstool.py'))
t.write_file('Procfile', 'web: python3 -u wstool.py server $PORT')
client = wstool.WebSocketClient(
f'ws://{t.current_test_domain}/',
connect_host='localhost',
connect_port=t.port,
host_header=t.current_test_domain
)
try:
client.connect()
t.await_log('Ready on port')
message1 = 'h%madsd4v$'
client.send(message1)
response1 = client.recv()
if response1.decode('utf-8') != message1:
raise AssertionError(f"Expected '{message1}', got: {response1}")
message2 = 'second message'
client.send(message2)
response2 = client.recv()
if response2.decode('utf-8') != message2:
raise AssertionError(f"Expected '{message2}', got: {response2}")
message3 = 'pipelined1'
message4 = 'pipelined2'
message5 = 'pipelined3'
client.send(message3)
client.send(message4)
client.send(message5)
response3 = client.recv()
response4 = client.recv()
response5 = client.recv()
if response3.decode('utf-8') != message3:
raise AssertionError(f"Expected '{message3}', got: {response3}")
if response4.decode('utf-8') != message4:
raise AssertionError(f"Expected '{message4}', got: {response4}")
if response5.decode('utf-8') != message5:
raise AssertionError(f"Expected '{message5}', got: {response5}")
finally:
client.close()
t.await_log(f"Echoed: {message1}", timeout=2)
t.assert_log(f"Echoed: {message2}", count=1)
t.assert_log(f"Echoed: {message3}", count=1)
t.assert_log(f"Echoed: {message4}", count=1)
t.assert_log(f"Echoed: {message5}", count=1)
@test
def test_forward_preserves_host_header(t):
t.write_file('server.py', '''
import os
import http.server
import socketserver
class HeaderEchoHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
# Echo back the Host header
host = self.headers.get('Host', 'no-host')
x_forwarded_host = self.headers.get('X-Forwarded-Host', 'no-x-forwarded-host')
x_forwarded_proto = self.headers.get('X-Forwarded-Proto', 'no-x-forwarded-proto')
path = self.path
response = f"Host: {host}\\nX-Forwarded-Host: {x_forwarded_host}\\nX-Forwarded-Proto: {x_forwarded_proto}\\nPath: {path}"
self.wfile.write(response.encode())
PORT = int(os.environ.get('PORT'))
with socketserver.TCPServer(("", PORT), HeaderEchoHandler) as httpd:
print(f"Header echo server on {PORT}", flush=True)
httpd.serve_forever()
''')
t.write_file('webcentral.ini', 'command=python3 -u server.py')
response = t.assert_http('/test/path', check_code=200)
if f'Host: {t.current_test_domain}' not in response:
raise AssertionError(f"Expected 'Host: {t.current_test_domain}' in response, got: {response}")
if 'X-Forwarded-Host: no-x-forwarded-host' not in response:
raise AssertionError(f"Expected no X-Forwarded-Host header in forward mode, got: {response}")
if 'Path: /test/path' not in response:
raise AssertionError(f"Expected 'Path: /test/path', got: {response}")
@test
def test_forward_tcp_port(t):
t.write_file('server.py', '''
import os
import http.server
import socketserver
class SimpleHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
host = self.headers.get('Host', 'unknown')
path = self.path
response = f"Backend received - Host: {host}, Path: {path}"
self.wfile.write(response.encode())
PORT = int(os.environ.get('PORT'))
print(f"Backend on {PORT}", flush=True)
with socketserver.TCPServer(("", PORT), SimpleHandler) as httpd:
httpd.serve_forever()
''')
t.write_file('webcentral.ini', 'command=python3 -u server.py')
t.assert_http('/', check_code=200)
t.await_log('Backend on')
backend_log = t.get_log_content(t.current_test_domain, 0)
import re
port_match = re.search(r'Backend on (\d+)', backend_log)
if not port_match:
raise AssertionError("Could not find backend port in logs")
backend_port = port_match.group(1)
t.write_file('webcentral.ini', f'port={backend_port}', domain='forwarder.test')
response = t.assert_http('/api/endpoint', check_code=200, host='forwarder.test')
if 'Host: forwarder.test' not in response:
raise AssertionError(f"Expected 'Host: forwarder.test', got: {response}")
if 'Path: /api/endpoint' not in response:
raise AssertionError(f"Expected 'Path: /api/endpoint', got: {response}")
@test
def test_proxy_rewrites_headers(t):
t.write_file('server.py', '''
import os
import http.server
import socketserver
class HeaderEchoHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
host = self.headers.get('Host', 'no-host')
x_forwarded_host = self.headers.get('X-Forwarded-Host', 'no-x-forwarded-host')
x_forwarded_proto = self.headers.get('X-Forwarded-Proto', 'no-x-forwarded-proto')
path = self.path
response = f"Host: {host}\\nX-Forwarded-Host: {x_forwarded_host}\\nX-Forwarded-Proto: {x_forwarded_proto}\\nPath: {path}"
self.wfile.write(response.encode())
PORT = int(os.environ.get('PORT'))
print(f"Proxy backend on {PORT}", flush=True)
with socketserver.TCPServer(("", PORT), HeaderEchoHandler) as httpd:
httpd.serve_forever()
''')
t.write_file('webcentral.ini', 'command=python3 -u server.py')
t.assert_http('/', check_code=200)
t.await_log('Proxy backend on')
backend_log = t.get_log_content(t.current_test_domain, 0)
import re
port_match = re.search(r'Proxy backend on (\d+)', backend_log)
if not port_match:
raise AssertionError("Could not find backend port")
backend_port = port_match.group(1)
t.write_file('webcentral.ini', f'proxy=http://localhost:{backend_port}', domain='proxy-test.test')
response = t.assert_http('/api/data', check_code=200, host='proxy-test.test')
if f'Host: localhost:{backend_port}' not in response:
raise AssertionError(f"Expected 'Host: localhost:{backend_port}' in proxy mode, got: {response}")
if 'X-Forwarded-Host: proxy-test.test' not in response:
raise AssertionError(f"Expected 'X-Forwarded-Host: proxy-test.test', got: {response}")
if 'X-Forwarded-Proto:' not in response or 'no-x-forwarded-proto' in response:
raise AssertionError(f"Expected X-Forwarded-Proto to be set, got: {response}")
if 'Path: /api/data' not in response:
raise AssertionError(f"Expected 'Path: /api/data', got: {response}")
@test
def test_proxy_vs_forward_path_handling(t):
t.write_file('server.py', '''
import os
import http.server
import socketserver
class PathHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
self.wfile.write(f"Path: {self.path}".encode())
PORT = int(os.environ.get('PORT'))
print(f"Path backend on {PORT}", flush=True)
with socketserver.TCPServer(("", PORT), PathHandler) as httpd:
httpd.serve_forever()
''')
t.write_file('webcentral.ini', 'command=python3 -u server.py')
t.assert_http('/', check_code=200)
t.await_log('Path backend on')
backend_log = t.get_log_content(t.current_test_domain, 0)
import re
port_match = re.search(r'Path backend on (\d+)', backend_log)
backend_port = port_match.group(1)
t.write_file('webcentral.ini', f'port={backend_port}', domain='path-forward.test')
t.write_file('webcentral.ini', f'proxy=http://localhost:{backend_port}', domain='path-proxy.test')
forward_response = t.assert_http('/some/path?query=value', check_code=200, host='path-forward.test')
proxy_response = t.assert_http('/some/path?query=value', check_code=200, host='path-proxy.test')
if 'Path: /some/path?query=value' not in forward_response:
raise AssertionError(f"Forward didn't preserve path, got: {forward_response}")
if 'Path: /some/path?query=value' not in proxy_response:
raise AssertionError(f"Proxy didn't preserve path, got: {proxy_response}")
@test
def test_forward_upstream_connect_error(t):
t.write_file('webcentral.ini', 'port=1')
t.assert_http('/', check_code=502)
t.await_log('upstream connect failed')
@test
def test_process_exit_during_startup_fast_restart(t):
t.write_file('server.py', '''
import sys
import time
print("Starting but will exit immediately", flush=True)
time.sleep(0.2) # Small delay to ensure log is written
sys.exit(1)
''')
t.write_file('webcentral.ini', 'command=python3 -u server.py')
t.write_file('public/index.html', '<h1>Static Fallback</h1>')
try:
t.assert_http('/', check_code=200, timeout=5)
except:
pass
t.await_log('Starting but will exit immediately', timeout=2)
t.await_log('Application failed to start listening on port', timeout=3)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Run webcentral tests')
parser.add_argument('--firejail', type=str, choices=['true', 'false'], default='true',
help='Enable or disable Firejail sandboxing (default: true)')
parser.add_argument('test_names', nargs='*', help='Specific test names to run')
args = parser.parse_args()
runner.use_firejail = args.firejail == 'true'
test_names = args.test_names if args.test_names else None
runner.run(test_names)