mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-03 17:06:36 +08:00
test: add Playwright e2e test suite for frontend and API
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Server lifecycle manager for E2E tests.
|
||||
Manages both Go backend and Vite frontend servers.
|
||||
|
||||
Usage:
|
||||
python with_server.py --help
|
||||
python with_server.py -- pytest test_login.py -v
|
||||
python with_server.py --server "make run" --port 6365 --server "npm run dev" --port 3000 -- pytest -v
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def find_free_port(start: int = 3000, max_tries: int = 100) -> int:
|
||||
"""Find an available port starting from `start`."""
|
||||
for port in range(start, start + max_tries):
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError(f"No free port found in range {start}-{start + max_tries}")
|
||||
|
||||
|
||||
def wait_for_port(port: int, host: str = "localhost", timeout: float = 30.0) -> bool:
|
||||
"""Wait for a port to become available."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(1)
|
||||
s.connect((host, port))
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
class ServerProcess:
|
||||
"""Manages a single server process."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
command: str,
|
||||
port: int,
|
||||
cwd: Optional[Path] = None,
|
||||
env: Optional[dict] = None,
|
||||
name: Optional[str] = None,
|
||||
ready_timeout: float = 30.0,
|
||||
):
|
||||
self.command = command
|
||||
self.port = port
|
||||
self.cwd = cwd
|
||||
self.env = env or {}
|
||||
self.name = name or f"server-{port}"
|
||||
self.ready_timeout = ready_timeout
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the server process."""
|
||||
env = os.environ.copy()
|
||||
env.update(self.env)
|
||||
|
||||
print(f"[{self.name}] Starting: {self.command}", file=sys.stderr)
|
||||
print(f"[{self.name}] Working directory: {self.cwd or '.'}", file=sys.stderr)
|
||||
print(f"[{self.name}] Expecting port: {self.port}", file=sys.stderr)
|
||||
|
||||
self.process = subprocess.Popen(
|
||||
self.command,
|
||||
shell=True,
|
||||
cwd=self.cwd,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
|
||||
if wait_for_port(self.port, timeout=self.ready_timeout):
|
||||
print(f"[{self.name}] Ready on port {self.port}", file=sys.stderr)
|
||||
return True
|
||||
else:
|
||||
print(f"[{self.name}] Failed to start (timeout)", file=sys.stderr)
|
||||
self.stop()
|
||||
return False
|
||||
|
||||
def stop(self):
|
||||
"""Stop the server process."""
|
||||
if self.process:
|
||||
try:
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
||||
self.process.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
self.process = None
|
||||
print(f"[{self.name}] Stopped", file=sys.stderr)
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the server is still running."""
|
||||
return self.process is not None and self.process.poll() is None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def managed_servers(servers: list[ServerProcess]):
|
||||
"""Context manager for multiple servers."""
|
||||
started = []
|
||||
try:
|
||||
for server in servers:
|
||||
if server.start():
|
||||
started.append(server)
|
||||
else:
|
||||
raise RuntimeError(f"Failed to start {server.name}")
|
||||
yield started
|
||||
finally:
|
||||
for server in reversed(started):
|
||||
server.stop()
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Server lifecycle manager for E2E tests",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Run all tests with default servers (backend + frontend)
|
||||
python with_server.py -- pytest -v
|
||||
|
||||
# Run specific test file
|
||||
python with_server.py -- pytest test_login.py -v
|
||||
|
||||
# Custom server configuration
|
||||
python with_server.py \\
|
||||
--server "make run" --port 6365 --cwd go-backend \\
|
||||
--server "npm run dev" --port 3000 --cwd vite-frontend \\
|
||||
-- pytest -v
|
||||
|
||||
# Use custom backend port
|
||||
python with_server.py --backend-port 8080 -- pytest -v
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--server",
|
||||
action="append",
|
||||
dest="servers",
|
||||
metavar="COMMAND",
|
||||
help="Server command to run (can be specified multiple times)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
action="append",
|
||||
dest="ports",
|
||||
type=int,
|
||||
metavar="PORT",
|
||||
help="Port for the corresponding --server (can be specified multiple times)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cwd",
|
||||
action="append",
|
||||
dest="cwds",
|
||||
metavar="DIR",
|
||||
help="Working directory for the corresponding --server",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--env",
|
||||
action="append",
|
||||
dest="envs",
|
||||
metavar="KEY=VALUE",
|
||||
help="Environment variable for the corresponding --server",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
action="append",
|
||||
dest="names",
|
||||
metavar="NAME",
|
||||
help="Name for the corresponding --server (for logging)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--backend-port",
|
||||
type=int,
|
||||
default=6365,
|
||||
help="Port for backend server (default: 6365)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--frontend-port",
|
||||
type=int,
|
||||
default=3000,
|
||||
help="Port for frontend server (default: 3000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend-cwd",
|
||||
default="go-backend",
|
||||
help="Working directory for backend (default: go-backend)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--frontend-cwd",
|
||||
default="vite-frontend",
|
||||
help="Working directory for frontend (default: vite-frontend)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--jwt-secret",
|
||||
default="test-secret-e2e-key-do-not-use-in-production",
|
||||
help="JWT secret for backend",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db-path",
|
||||
default=":memory:",
|
||||
help="Database path for backend (default: :memory: for SQLite in-memory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=30.0,
|
||||
help="Timeout for server startup (default: 30s)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"command",
|
||||
nargs=argparse.REMAINDER,
|
||||
help="Command to run after servers start (use -- to separate)",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def build_servers(args) -> list[ServerProcess]:
|
||||
"""Build the list of servers to start."""
|
||||
servers = []
|
||||
root = Path(__file__).parent.parent.parent
|
||||
|
||||
if args.servers:
|
||||
# Custom server configuration
|
||||
for i, cmd in enumerate(args.servers):
|
||||
port = (
|
||||
args.ports[i]
|
||||
if args.ports and i < len(args.ports)
|
||||
else find_free_port()
|
||||
)
|
||||
cwd = Path(args.cwds[i]) if args.cwds and i < len(args.cwds) else root
|
||||
if not cwd.is_absolute():
|
||||
cwd = root / cwd
|
||||
name = (
|
||||
args.names[i]
|
||||
if args.names and i < len(args.names)
|
||||
else f"server-{port}"
|
||||
)
|
||||
|
||||
env = {}
|
||||
if args.envs:
|
||||
for j, e in enumerate(args.envs):
|
||||
if "=" in e:
|
||||
k, v = e.split("=", 1)
|
||||
env[k] = v
|
||||
|
||||
servers.append(
|
||||
ServerProcess(
|
||||
command=cmd,
|
||||
port=port,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
name=name,
|
||||
ready_timeout=args.timeout,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Default configuration: backend + frontend
|
||||
backend_env = {
|
||||
"SERVER_ADDR": f":{args.backend_port}",
|
||||
"JWT_SECRET": args.jwt_secret,
|
||||
"DB_PATH": args.db_path,
|
||||
}
|
||||
|
||||
servers.append(
|
||||
ServerProcess(
|
||||
command="go run ./cmd/paneld",
|
||||
port=args.backend_port,
|
||||
cwd=root / args.backend_cwd,
|
||||
env=backend_env,
|
||||
name="backend",
|
||||
ready_timeout=args.timeout,
|
||||
)
|
||||
)
|
||||
|
||||
frontend_env = {
|
||||
"VITE_API_BASE": f"http://localhost:{args.backend_port}",
|
||||
}
|
||||
|
||||
servers.append(
|
||||
ServerProcess(
|
||||
command="npm run dev",
|
||||
port=args.frontend_port,
|
||||
cwd=root / args.frontend_cwd,
|
||||
env=frontend_env,
|
||||
name="frontend",
|
||||
ready_timeout=args.timeout,
|
||||
)
|
||||
)
|
||||
|
||||
return servers
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
if args.command[0] == "--":
|
||||
args.command = args.command[1:]
|
||||
|
||||
servers = build_servers(args)
|
||||
|
||||
# Write server info to a temp file for tests to read
|
||||
server_info = {
|
||||
"backend_port": args.backend_port
|
||||
if not args.servers
|
||||
else servers[0].port
|
||||
if servers
|
||||
else 6365,
|
||||
"frontend_port": args.frontend_port
|
||||
if not args.servers
|
||||
else servers[1].port
|
||||
if len(servers) > 1
|
||||
else 3000,
|
||||
"jwt_secret": args.jwt_secret,
|
||||
}
|
||||
|
||||
info_file = Path(__file__).parent / ".server_info.json"
|
||||
with open(info_file, "w") as f:
|
||||
json.dump(server_info, f)
|
||||
|
||||
# Set environment variables for tests
|
||||
os.environ["E2E_BACKEND_PORT"] = str(server_info["backend_port"])
|
||||
os.environ["E2E_FRONTEND_PORT"] = str(server_info["frontend_port"])
|
||||
os.environ["E2E_JWT_SECRET"] = server_info["jwt_secret"]
|
||||
|
||||
exit_code = 1
|
||||
try:
|
||||
with managed_servers(servers) as started:
|
||||
if not started:
|
||||
print("No servers started", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Run the test command
|
||||
print(f"Running: {' '.join(args.command)}", file=sys.stderr)
|
||||
result = subprocess.run(args.command)
|
||||
exit_code = result.returncode
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted", file=sys.stderr)
|
||||
exit_code = 130
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
exit_code = 1
|
||||
finally:
|
||||
if info_file.exists():
|
||||
info_file.unlink()
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user