Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .devcontainer/devcontainer_setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ if [ -f "package.json" ]; then
npm install

# Install Playwright browsers and system dependencies for E2E testing
# This may fail if apt repos have signature issues - don't block setup
echo "📦 Installing Playwright browsers..."

# Remove third-party repos with SHA1 signature issues (rejected since 2026-02-01)
Expand All @@ -78,7 +79,11 @@ if [ -f "package.json" ]; then
/etc/apt/sources.list.d/nodesource.list \
/etc/apt/sources.list.d/microsoft.list 2>/dev/null || true

npx playwright install --with-deps chromium
if npx playwright install --with-deps chromium; then
echo "✅ Playwright browsers installed."
else
echo "⚠️ Playwright installation failed (apt signature issues). Run 'npx playwright install chromium' manually if needed for E2E tests."
fi

echo "✅ Frontend dependencies installed."
fi
Expand Down
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
* text=auto eol=lf
# Squad: union merge for append-only team state files
.squad/decisions.md merge=union
.squad/agents/*/history.md merge=union
.squad/log/** merge=union
.squad/orchestration-log/** merge=union
4 changes: 2 additions & 2 deletions .github/workflows/frontend_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,8 @@ jobs:
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

- name: Run E2E tests
run: npm run test:e2e
- name: Run E2E tests (seeded mode)
run: npm run test:e2e:seeded
env:
CI: true

Expand Down
21 changes: 21 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,27 @@ npm run test:e2e:headed # Run with visible browser windows (requires display)
npm run test:e2e:ui # Interactive UI mode (requires display)
```

### E2E Test Modes

E2E flow tests run in two modes controlled by Playwright projects and an environment variable:

- **Seeded** (`--project seeded`, default for CI): Messages are stored directly in the database with `send: false` using dummy credentials. No real API keys needed. Tests cover the full UI flow (display, branching, conversation switching, promoting) without calling any external service.

- **Live** (`--project live`, requires `E2E_LIVE_MODE=true`): Messages are sent to real OpenAI endpoints with `send: true`. Each target variant requires its own set of environment variables (e.g., `OPENAI_CHAT_ENDPOINT`, `OPENAI_CHAT_KEY`, `OPENAI_CHAT_MODEL`). Variants whose env vars are missing are automatically skipped. Tests verify that real target responses render correctly.

```bash
# CI (seeded only — no credentials needed)
npx playwright test --project seeded

# Live integration (requires real API keys)
E2E_LIVE_MODE=true npx playwright test --project live

# Run both
E2E_LIVE_MODE=true npx playwright test
```

The seeded project runs in the **GitHub Actions** workflow. The live project is intended for an **Azure DevOps pipeline** that has the required secret API keys.

E2E tests use `dev.py` to automatically start both frontend and backend servers. If servers are already running, they will be reused.

> **Note**: `test:e2e:ui` and `test:e2e:headed` require a graphical display and won't work in headless environments like devcontainers. Use `npm run test:e2e` for CI/headless testing.
Expand Down
174 changes: 145 additions & 29 deletions frontend/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import json
import os
import platform
import signal
import subprocess
import sys
import time
Expand All @@ -22,6 +23,8 @@
# Determine workspace root (parent of frontend directory)
FRONTEND_DIR = Path(__file__).parent.absolute()
WORKSPACE_ROOT = FRONTEND_DIR.parent
DEVPY_LOG_FILE = Path.home() / ".pyrit" / "dev.log"
DEVPY_PID_FILE = Path.home() / ".pyrit" / "dev.pid"


def is_windows():
Expand Down Expand Up @@ -56,38 +59,74 @@ def sync_version():
print(f"⚠️ Warning: Could not sync version: {e}")


def kill_process_by_pattern(pattern):
"""Kill processes matching a pattern (cross-platform)"""
def find_pids_by_pattern(pattern):
"""Find PIDs of processes matching a pattern (cross-platform).

Returns:
list[int]: List of matching process IDs.
"""
pids = []
try:
if is_windows():
# Windows: use taskkill
subprocess.run(
f'taskkill /F /FI "COMMANDLINE like %{pattern}%" >nul 2>&1',
shell=True,
result = subprocess.run(
["wmic", "process", "where", f"CommandLine like '%{pattern}%'", "get", "ProcessId"],
capture_output=True,
text=True,
check=False,
)
for line in result.stdout.strip().splitlines():
line = line.strip()
if line.isdigit():
pids.append(int(line))
else:
# Unix: use pkill
subprocess.run(["pkill", "-f", pattern], check=False, stderr=subprocess.DEVNULL)
except Exception as e:
print(f"Warning: Could not kill {pattern}: {e}")
result = subprocess.run(
["pgrep", "-f", pattern],
capture_output=True,
text=True,
check=False,
)
for line in result.stdout.strip().splitlines():
line = line.strip()
if line.isdigit():
pid = int(line)
# Don't include our own process
if pid != os.getpid():
pids.append(pid)
except Exception:
pass
return pids


def kill_pids(pids):
"""Kill a list of processes by PID."""
for pid in pids:
try:
os.kill(pid, signal.SIGTERM)
except OSError:
pass


def stop_servers():
"""Stop all running servers"""
print("🛑 Stopping servers...")
kill_process_by_pattern("pyrit.backend.main")
kill_process_by_pattern("vite")
time.sleep(1)
backend_pids = find_pids_by_pattern("pyrit.cli.pyrit_backend")
frontend_pids = find_pids_by_pattern("node.*vite")
# Also find any parent dev.py processes (detached wrappers)
wrapper_pids = find_pids_by_pattern("frontend/dev.py")
all_pids = backend_pids + frontend_pids + wrapper_pids
if all_pids:
print(f" Killing PIDs: {all_pids}")
kill_pids(all_pids)
time.sleep(1)
print("✅ Servers stopped")


def start_backend(initializers: list[str] | None = None):
def start_backend(*, config_file: str | None = None, initializers: list[str] | None = None):
"""Start the FastAPI backend using pyrit_backend CLI.

Args:
initializers: Optional list of initializer names to run at startup.
If not specified, no initializers are run.
Configuration (initializers, database, env files) is read automatically
from ~/.pyrit/.pyrit_conf by the pyrit_backend CLI via ConfigurationLoader,
unless overridden with *config_file*.
"""
print("🚀 Starting backend on port 8000...")

Expand All @@ -98,11 +137,6 @@ def start_backend(initializers: list[str] | None = None):
env = os.environ.copy()
env["PYRIT_DEV_MODE"] = "true"

# Default to no initializers
if initializers is None:
initializers = []

# Build command using pyrit_backend CLI
cmd = [
sys.executable,
"-m",
Expand All @@ -114,6 +148,8 @@ def start_backend(initializers: list[str] | None = None):
"--log-level",
"info",
]
if config_file:
cmd.extend(["--config-file", config_file])

# Add initializers if specified
if initializers:
Expand All @@ -135,12 +171,15 @@ def start_frontend():
return subprocess.Popen([npm_cmd, "run", "dev"])


def start_servers():
def start_servers(*, config_file: str | None = None):
"""Start both backend and frontend servers"""
print("🚀 Starting PyRIT UI servers...")
print()

backend = start_backend()
# Kill any stale processes from prior sessions
stop_servers()

backend = start_backend(config_file=config_file)
print("⏳ Waiting for backend to initialize...")
time.sleep(5) # Give backend more time to fully start up

Expand Down Expand Up @@ -184,25 +223,88 @@ def wait_for_interrupt(backend, frontend):
print("✅ Servers stopped")


def start_detached(*, config_file: str | None = None):
"""Re-launch this script in a fully detached background process.

The detached process writes stdout/stderr to DEVPY_LOG_FILE and its PID
is recorded in DEVPY_PID_FILE so ``stop`` can find it.
"""
DEVPY_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)

cmd = [sys.executable, str(Path(__file__).absolute())]
if config_file:
cmd.extend(["--config-file", config_file])

log_fh = open(DEVPY_LOG_FILE, "w")
proc = subprocess.Popen(
cmd,
stdout=log_fh,
stderr=subprocess.STDOUT,
start_new_session=True,
)
DEVPY_PID_FILE.write_text(str(proc.pid))
print(f"🚀 dev.py started in background (PID: {proc.pid})")
print(f" Logs: {DEVPY_LOG_FILE}")
print(f" Stop: python {Path(__file__).name} stop")


def show_logs(*, follow: bool = False, lines: int = 50):
"""Show dev.py logs."""
if not DEVPY_LOG_FILE.exists():
print(f"No log file found at {DEVPY_LOG_FILE}")
return
if follow:
subprocess.run(["tail", "-f", "-n", str(lines), str(DEVPY_LOG_FILE)])
else:
subprocess.run(["tail", "-n", str(lines), str(DEVPY_LOG_FILE)])


def main():
"""Main entry point"""
# Sync version before any operation
sync_version()

if len(sys.argv) > 1:
command = sys.argv[1].lower()
# Extract --config-file and --detach from argv
config_file: str | None = None
detach = False
argv = list(sys.argv[1:])
if "--config-file" in argv:
idx = argv.index("--config-file")
if idx + 1 < len(argv):
config_file = argv[idx + 1]
argv = argv[:idx] + argv[idx + 2:]
else:
print("ERROR: --config-file requires a path argument")
sys.exit(1)
if "--detach" in argv:
argv.remove("--detach")
detach = True

if argv:
command = argv[0].lower()

if command == "stop":
stop_servers()
return
if command == "restart":
stop_servers()
time.sleep(1)
# Fall through to start
elif command == "start":
pass # Just start both
elif command == "logs":
follow = "-f" in argv or "--follow" in argv
show_logs(follow=follow)
return
elif command == "backend":
print("🚀 Starting backend only...")
backend = start_backend()
# Kill stale backend processes
stale = find_pids_by_pattern("pyrit.cli.pyrit_backend")
if stale:
print(f" Killing stale backend PIDs: {stale}")
kill_pids(stale)
time.sleep(1)
backend = start_backend(config_file=config_file)
print(f"✅ Backend running on http://localhost:8000 (PID: {backend.pid})")
print(" API Docs: http://localhost:8000/docs")
print("\nPress Ctrl+C to stop")
Expand All @@ -216,6 +318,12 @@ def main():
return
elif command == "frontend":
print("🎨 Starting frontend only...")
# Kill stale frontend processes
stale = find_pids_by_pattern("node.*vite")
if stale:
print(f" Killing stale frontend PIDs: {stale}")
kill_pids(stale)
time.sleep(1)
frontend = start_frontend()
print(f"✅ Frontend running on http://localhost:3000 (PID: {frontend.pid})")
print("\nPress Ctrl+C to stop")
Expand All @@ -229,11 +337,19 @@ def main():
return
else:
print(f"Unknown command: {command}")
print("Usage: python dev.py [start|stop|restart|backend|frontend]")
print(
"Usage: python dev.py [start|stop|restart|backend|frontend|logs] "
"[--config-file PATH] [--detach]"
)
sys.exit(1)

# If --detach, re-launch in background and exit immediately
if detach:
start_detached(config_file=config_file)
return

# Start servers
backend, frontend = start_servers()
backend, frontend = start_servers(config_file=config_file)

# Wait for interrupt
wait_for_interrupt(backend, frontend)
Expand Down
Loading
Loading