feat: add playwright-ephemeral skill for ephemeral browser provisioning
Adds a new skill that provisions ephemeral Playwright MCP browser sessions as Kubernetes Jobs for E2E testing. Includes provision and teardown scripts, K8s Job/Service YAML templates, and agent-facing SKILL.md documentation. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -15,10 +15,11 @@ Each skill follows this convention:
|
||||
## Current Skills
|
||||
|
||||
- **`github-app-token`** — Generates short-lived GitHub App installation access tokens. Requires `GITHUB_APP_ID`, `GITHUB_APP_INSTALLATION_ID`, and `GITHUB_APP_PEM_FILE` env vars. Use `--raw` flag to get the token value directly (recommended for agents), or omit for legacy `eval`-based `export GH_TOKEN=...` output.
|
||||
- **`playwright-ephemeral`** — Provisions ephemeral Playwright MCP browser sessions as Kubernetes Jobs for E2E testing. Creates a Job + Service pair in a dedicated namespace, waits for readiness, and returns the MCP endpoint URL. Requires `kubectl` and appropriate RBAC.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- Scripts are pure bash with no external dependencies beyond standard Unix tools (`openssl`, `curl`, `jq`).
|
||||
- Scripts are pure bash with no external dependencies beyond standard Unix tools (`openssl`, `curl`, `jq`, `kubectl`).
|
||||
- The `--raw` output pattern (preferred): scripts with `--raw` print only the value to stdout for easy `$(...)` capture. The legacy `eval` pattern (no flag) prints shell commands like `export VAR="value"` for backward compatibility.
|
||||
- The `die()` function prints errors to stderr and exits non-zero.
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: playwright-ephemeral
|
||||
description: Provision and tear down ephemeral Playwright MCP browser sessions as Kubernetes Jobs for E2E testing.
|
||||
---
|
||||
|
||||
# Ephemeral Playwright Browser Provisioning
|
||||
|
||||
Provision an ephemeral Playwright MCP browser session running in a Kubernetes Job. Use this when you need to drive a real browser for E2E or integration testing.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Description |
|
||||
|---|---|
|
||||
| `kubectl` | Must be available and configured with cluster access |
|
||||
| RBAC | Agent service account needs Jobs and Services CRUD in `playwright-sessions` namespace |
|
||||
| Network | Cluster networking must allow traffic from agent pod to `playwright-sessions` namespace |
|
||||
|
||||
## When to Use
|
||||
|
||||
- You need to drive a real Chromium browser (click, navigate, screenshot, scrape)
|
||||
- You are running E2E or integration tests against a web application
|
||||
- You need a Playwright MCP server endpoint to connect your MCP client to
|
||||
|
||||
## Provision a Session
|
||||
|
||||
Run the provision script. It creates a Kubernetes Job + Service pair and waits until the Playwright MCP server is accepting connections.
|
||||
|
||||
```bash
|
||||
RESULT=$(bash ./playwright-ephemeral/scripts/provision.sh)
|
||||
```
|
||||
|
||||
On success, the script prints two lines to stdout:
|
||||
|
||||
```
|
||||
SESSION_NAME=playwright-<agent>-<uuid>
|
||||
MCP_URL=http://playwright-<agent>-<uuid>.playwright-sessions.svc.cluster.local:8931/mcp
|
||||
```
|
||||
|
||||
Extract the values:
|
||||
|
||||
```bash
|
||||
SESSION_NAME=$(echo "$RESULT" | grep '^SESSION_NAME=' | cut -d= -f2)
|
||||
MCP_URL=$(echo "$RESULT" | grep '^MCP_URL=' | cut -d= -f2)
|
||||
```
|
||||
|
||||
### Optional Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `PLAYWRIGHT_NAMESPACE` | `playwright-sessions` | Kubernetes namespace for browser pods |
|
||||
| `PLAYWRIGHT_TIMEOUT` | `120` | Seconds to wait for the MCP server to become ready |
|
||||
| `PLAYWRIGHT_TTL` | `1800` | TTL in seconds after Job finishes (auto-cleanup) |
|
||||
| `PLAYWRIGHT_DEADLINE` | `1800` | Hard ceiling in seconds for the Job (kills zombie sessions) |
|
||||
| `PLAYWRIGHT_MEMORY_REQUEST` | `512Mi` | Memory request for the browser container |
|
||||
| `PLAYWRIGHT_MEMORY_LIMIT` | `1Gi` | Memory limit for the browser container |
|
||||
|
||||
## Connect to the Session
|
||||
|
||||
Configure your Playwright MCP client with the returned `MCP_URL`. The endpoint speaks HTTP-based MCP transport on port 8931 at the `/mcp` path.
|
||||
|
||||
Example: if `MCP_URL=http://playwright-goose-a1b2c3.playwright-sessions.svc.cluster.local:8931/mcp`, point your MCP client at that URL.
|
||||
|
||||
## Tear Down a Session
|
||||
|
||||
When you are finished with the browser, tear it down:
|
||||
|
||||
```bash
|
||||
bash ./playwright-ephemeral/scripts/teardown.sh "$SESSION_NAME"
|
||||
```
|
||||
|
||||
This deletes both the Job and Service. If you forget, the Job self-cleans after `PLAYWRIGHT_TTL` seconds and hard-terminates after `PLAYWRIGHT_DEADLINE` seconds.
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | What Happens |
|
||||
|---|---|
|
||||
| Pod fails to schedule | Provision script times out and exits non-zero with an error message |
|
||||
| MCP server not ready in time | Provision script times out and cleans up the Job/Service before exiting |
|
||||
| kubectl not found | Script exits immediately with an error |
|
||||
| Namespace does not exist | Script creates the namespace automatically |
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Each session runs in an isolated pod with its own network identity.
|
||||
- Sessions are ephemeral — the Job TTL and active deadline prevent resource leaks.
|
||||
- The browser runs with `--no-sandbox` (required in containers) and headless Chromium only.
|
||||
- No data persists after teardown.
|
||||
@@ -0,0 +1,51 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: "{{SESSION_NAME}}"
|
||||
namespace: "{{NAMESPACE}}"
|
||||
labels:
|
||||
app: playwright-mcp
|
||||
session: "{{SESSION_NAME}}"
|
||||
spec:
|
||||
ttlSecondsAfterFinished: {{TTL}}
|
||||
activeDeadlineSeconds: {{DEADLINE}}
|
||||
backoffLimit: 0
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: playwright-mcp
|
||||
session: "{{SESSION_NAME}}"
|
||||
spec:
|
||||
shareProcessNamespace: true
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: playwright-mcp
|
||||
image: mcr.microsoft.com/playwright/mcp
|
||||
command: ["node"]
|
||||
args:
|
||||
- "cli.js"
|
||||
- "--headless"
|
||||
- "--browser"
|
||||
- "chromium"
|
||||
- "--no-sandbox"
|
||||
- "--port"
|
||||
- "8931"
|
||||
- "--host"
|
||||
- "0.0.0.0"
|
||||
ports:
|
||||
- containerPort: 8931
|
||||
protocol: TCP
|
||||
resources:
|
||||
requests:
|
||||
memory: "{{MEMORY_REQUEST}}"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "{{MEMORY_LIMIT}}"
|
||||
cpu: "1"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /mcp
|
||||
port: 8931
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 3
|
||||
failureThreshold: 20
|
||||
@@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: "{{SESSION_NAME}}"
|
||||
namespace: "{{NAMESPACE}}"
|
||||
labels:
|
||||
app: playwright-mcp
|
||||
session: "{{SESSION_NAME}}"
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
session: "{{SESSION_NAME}}"
|
||||
ports:
|
||||
- port: 8931
|
||||
targetPort: 8931
|
||||
protocol: TCP
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
# --- Dependencies ---
|
||||
command -v kubectl >/dev/null 2>&1 || die "kubectl is not installed or not in PATH"
|
||||
|
||||
# --- Configuration ---
|
||||
NAMESPACE="${PLAYWRIGHT_NAMESPACE:-playwright-sessions}"
|
||||
TIMEOUT="${PLAYWRIGHT_TIMEOUT:-120}"
|
||||
TTL="${PLAYWRIGHT_TTL:-1800}"
|
||||
DEADLINE="${PLAYWRIGHT_DEADLINE:-1800}"
|
||||
MEMORY_REQUEST="${PLAYWRIGHT_MEMORY_REQUEST:-512Mi}"
|
||||
MEMORY_LIMIT="${PLAYWRIGHT_MEMORY_LIMIT:-1Gi}"
|
||||
|
||||
# Generate unique session name
|
||||
AGENT_NAME="${PAPERCLIP_AGENT_ID:-agent}"
|
||||
# Use first 8 chars of agent ID + random suffix
|
||||
AGENT_SHORT="${AGENT_NAME:0:8}"
|
||||
SHORT_UUID=$(head -c 6 /dev/urandom | od -An -tx1 | tr -d ' \n' | head -c 8)
|
||||
SESSION_NAME="playwright-${AGENT_SHORT}-${SHORT_UUID}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REF_DIR="${SCRIPT_DIR}/../references"
|
||||
|
||||
# --- Ensure namespace exists ---
|
||||
if ! kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then
|
||||
echo "Creating namespace $NAMESPACE..." >&2
|
||||
kubectl create namespace "$NAMESPACE"
|
||||
fi
|
||||
|
||||
# --- Render and apply Job manifest ---
|
||||
sed \
|
||||
-e "s|{{SESSION_NAME}}|${SESSION_NAME}|g" \
|
||||
-e "s|{{NAMESPACE}}|${NAMESPACE}|g" \
|
||||
-e "s|{{TTL}}|${TTL}|g" \
|
||||
-e "s|{{DEADLINE}}|${DEADLINE}|g" \
|
||||
-e "s|{{MEMORY_REQUEST}}|${MEMORY_REQUEST}|g" \
|
||||
-e "s|{{MEMORY_LIMIT}}|${MEMORY_LIMIT}|g" \
|
||||
"$REF_DIR/job-template.yaml" | kubectl apply -f - >&2
|
||||
|
||||
# --- Render and apply Service manifest ---
|
||||
sed \
|
||||
-e "s|{{SESSION_NAME}}|${SESSION_NAME}|g" \
|
||||
-e "s|{{NAMESPACE}}|${NAMESPACE}|g" \
|
||||
"$REF_DIR/svc-template.yaml" | kubectl apply -f - >&2
|
||||
|
||||
# --- Wait for pod to be Ready ---
|
||||
echo "Waiting for pod to be ready (timeout: ${TIMEOUT}s)..." >&2
|
||||
SECONDS=0
|
||||
POD_READY=false
|
||||
|
||||
while [ "$SECONDS" -lt "$TIMEOUT" ]; do
|
||||
POD_NAME=$(kubectl get pods -n "$NAMESPACE" -l "session=${SESSION_NAME}" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
|
||||
|
||||
if [ -n "$POD_NAME" ]; then
|
||||
PHASE=$(kubectl get pod -n "$NAMESPACE" "$POD_NAME" -o jsonpath='{.status.phase}' 2>/dev/null || true)
|
||||
if [ "$PHASE" = "Running" ]; then
|
||||
READY=$(kubectl get pod -n "$NAMESPACE" "$POD_NAME" -o jsonpath='{.status.containerStatuses[0].ready}' 2>/dev/null || true)
|
||||
if [ "$READY" = "true" ]; then
|
||||
POD_READY=true
|
||||
break
|
||||
fi
|
||||
elif [ "$PHASE" = "Failed" ] || [ "$PHASE" = "Succeeded" ]; then
|
||||
# Cleanup on failure
|
||||
echo "Pod entered $PHASE state unexpectedly. Cleaning up..." >&2
|
||||
kubectl delete job "$SESSION_NAME" -n "$NAMESPACE" --ignore-not-found >&2
|
||||
kubectl delete service "$SESSION_NAME" -n "$NAMESPACE" --ignore-not-found >&2
|
||||
die "Pod failed to start (phase: $PHASE)"
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [ "$POD_READY" != "true" ]; then
|
||||
echo "Timed out waiting for MCP server. Cleaning up..." >&2
|
||||
kubectl delete job "$SESSION_NAME" -n "$NAMESPACE" --ignore-not-found >&2
|
||||
kubectl delete service "$SESSION_NAME" -n "$NAMESPACE" --ignore-not-found >&2
|
||||
die "Playwright MCP server did not become ready within ${TIMEOUT}s"
|
||||
fi
|
||||
|
||||
MCP_URL="http://${SESSION_NAME}.${NAMESPACE}.svc.cluster.local:8931/mcp"
|
||||
|
||||
echo "Playwright MCP session ready." >&2
|
||||
echo "SESSION_NAME=${SESSION_NAME}"
|
||||
echo "MCP_URL=${MCP_URL}"
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
# --- Dependencies ---
|
||||
command -v kubectl >/dev/null 2>&1 || die "kubectl is not installed or not in PATH"
|
||||
|
||||
# --- Arguments ---
|
||||
SESSION_NAME="${1:-}"
|
||||
[ -n "$SESSION_NAME" ] || die "Usage: teardown.sh <session-name>"
|
||||
|
||||
NAMESPACE="${PLAYWRIGHT_NAMESPACE:-playwright-sessions}"
|
||||
|
||||
# --- Delete Job and Service ---
|
||||
echo "Tearing down session: ${SESSION_NAME} in namespace: ${NAMESPACE}" >&2
|
||||
|
||||
kubectl delete job "$SESSION_NAME" -n "$NAMESPACE" --ignore-not-found >&2
|
||||
echo "Job deleted." >&2
|
||||
|
||||
kubectl delete service "$SESSION_NAME" -n "$NAMESPACE" --ignore-not-found >&2
|
||||
echo "Service deleted." >&2
|
||||
|
||||
echo "Session ${SESSION_NAME} torn down successfully." >&2
|
||||
Reference in New Issue
Block a user