mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 21:46:58 -05:00
Compare commits
2 Commits
41ae88d1bc
...
72a7cb7f7c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72a7cb7f7c | ||
|
|
8ac61e01e3 |
240
.github/workflows/playwright.yml
vendored
Normal file
240
.github/workflows/playwright.yml
vendored
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
name: Playwright E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, develop]
|
||||||
|
|
||||||
|
env:
|
||||||
|
GRAFANA_LOKI_URL: ${{ secrets.GRAFANA_LOKI_URL }}
|
||||||
|
GRAFANA_LOKI_USERNAME: ${{ secrets.GRAFANA_LOKI_USERNAME }}
|
||||||
|
GRAFANA_LOKI_PASSWORD: ${{ secrets.GRAFANA_LOKI_PASSWORD }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Pre-flight validation to ensure environment is ready
|
||||||
|
preflight:
|
||||||
|
name: Validate Environment
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check Required Secrets
|
||||||
|
run: |
|
||||||
|
echo "🔍 Validating required secrets..."
|
||||||
|
if [ -z "${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}" ]; then
|
||||||
|
echo "❌ SUPABASE_SERVICE_ROLE_KEY is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "${{ secrets.TEST_USER_EMAIL }}" ]; then
|
||||||
|
echo "⚠️ TEST_USER_EMAIL is not set"
|
||||||
|
fi
|
||||||
|
echo "✅ Required secrets validated"
|
||||||
|
|
||||||
|
- name: Check Loki Connection
|
||||||
|
if: ${{ secrets.GRAFANA_LOKI_URL != '' }}
|
||||||
|
run: |
|
||||||
|
echo "🔍 Testing Loki connection..."
|
||||||
|
if [ -n "${{ secrets.GRAFANA_LOKI_USERNAME }}" ]; then
|
||||||
|
response=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-u "${{ secrets.GRAFANA_LOKI_USERNAME }}:${{ secrets.GRAFANA_LOKI_PASSWORD }}" \
|
||||||
|
"${{ secrets.GRAFANA_LOKI_URL }}/ready")
|
||||||
|
else
|
||||||
|
response=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
"${{ secrets.GRAFANA_LOKI_URL }}/ready")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$response" = "200" ]; then
|
||||||
|
echo "✅ Loki is ready at ${{ secrets.GRAFANA_LOKI_URL }}"
|
||||||
|
else
|
||||||
|
echo "⚠️ Loki connection check returned HTTP $response"
|
||||||
|
echo "Tests will continue but logs may not be sent to Loki"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Send Pre-flight Event to Loki
|
||||||
|
if: ${{ secrets.GRAFANA_LOKI_URL != '' }}
|
||||||
|
run: |
|
||||||
|
timestamp=$(date +%s)000000000
|
||||||
|
auth_header=""
|
||||||
|
if [ -n "${{ secrets.GRAFANA_LOKI_USERNAME }}" ]; then
|
||||||
|
auth_header="-u ${{ secrets.GRAFANA_LOKI_USERNAME }}:${{ secrets.GRAFANA_LOKI_PASSWORD }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -X POST "${{ secrets.GRAFANA_LOKI_URL }}/loki/api/v1/push" \
|
||||||
|
$auth_header \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"streams\": [{
|
||||||
|
\"stream\": {
|
||||||
|
\"job\": \"playwright-preflight\",
|
||||||
|
\"workflow\": \"${{ github.workflow }}\",
|
||||||
|
\"branch\": \"${{ github.ref_name }}\",
|
||||||
|
\"commit\": \"${{ github.sha }}\",
|
||||||
|
\"run_id\": \"${{ github.run_id }}\",
|
||||||
|
\"event\": \"preflight_complete\"
|
||||||
|
},
|
||||||
|
\"values\": [[\"$timestamp\", \"Pre-flight checks completed successfully\"]]
|
||||||
|
}]
|
||||||
|
}" || echo "⚠️ Failed to send pre-flight event to Loki"
|
||||||
|
|
||||||
|
test:
|
||||||
|
needs: preflight
|
||||||
|
timeout-minutes: 60
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
browser: [chromium, firefox, webkit]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
run: npx playwright install --with-deps ${{ matrix.browser }}
|
||||||
|
|
||||||
|
- name: Send Test Start Event to Loki
|
||||||
|
if: ${{ secrets.GRAFANA_LOKI_URL != '' }}
|
||||||
|
run: |
|
||||||
|
timestamp=$(date +%s)000000000
|
||||||
|
auth_header=""
|
||||||
|
if [ -n "${{ secrets.GRAFANA_LOKI_USERNAME }}" ]; then
|
||||||
|
auth_header="-u ${{ secrets.GRAFANA_LOKI_USERNAME }}:${{ secrets.GRAFANA_LOKI_PASSWORD }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -X POST "${{ secrets.GRAFANA_LOKI_URL }}/loki/api/v1/push" \
|
||||||
|
$auth_header \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"streams\": [{
|
||||||
|
\"stream\": {
|
||||||
|
\"job\": \"playwright-tests\",
|
||||||
|
\"browser\": \"${{ matrix.browser }}\",
|
||||||
|
\"workflow\": \"${{ github.workflow }}\",
|
||||||
|
\"branch\": \"${{ github.ref_name }}\",
|
||||||
|
\"commit\": \"${{ github.sha }}\",
|
||||||
|
\"run_id\": \"${{ github.run_id }}\",
|
||||||
|
\"event\": \"test_start\"
|
||||||
|
},
|
||||||
|
\"values\": [[\"$timestamp\", \"Starting Playwright tests for ${{ matrix.browser }}\"]]
|
||||||
|
}]
|
||||||
|
}" || echo "⚠️ Failed to send start event to Loki"
|
||||||
|
|
||||||
|
- name: Run Playwright tests
|
||||||
|
id: playwright-run
|
||||||
|
env:
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
|
||||||
|
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
|
||||||
|
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
|
||||||
|
TEST_MODERATOR_EMAIL: ${{ secrets.TEST_MODERATOR_EMAIL }}
|
||||||
|
TEST_MODERATOR_PASSWORD: ${{ secrets.TEST_MODERATOR_PASSWORD }}
|
||||||
|
BASE_URL: ${{ secrets.BASE_URL || 'http://localhost:8080' }}
|
||||||
|
# Enable Loki reporter
|
||||||
|
GRAFANA_LOKI_URL: ${{ secrets.GRAFANA_LOKI_URL }}
|
||||||
|
GRAFANA_LOKI_USERNAME: ${{ secrets.GRAFANA_LOKI_USERNAME }}
|
||||||
|
GRAFANA_LOKI_PASSWORD: ${{ secrets.GRAFANA_LOKI_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
echo "🧪 Running Playwright tests for ${{ matrix.browser }}..."
|
||||||
|
npx playwright test --project=${{ matrix.browser }} 2>&1 | tee test-execution.log
|
||||||
|
TEST_EXIT_CODE=${PIPESTATUS[0]}
|
||||||
|
echo "test_exit_code=$TEST_EXIT_CODE" >> $GITHUB_OUTPUT
|
||||||
|
exit $TEST_EXIT_CODE
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Parse Test Results
|
||||||
|
if: always()
|
||||||
|
id: parse-results
|
||||||
|
run: |
|
||||||
|
if [ -f "test-results.json" ]; then
|
||||||
|
echo "📊 Parsing test results..."
|
||||||
|
TOTAL=$(jq '[.suites[].specs[]] | length' test-results.json || echo "0")
|
||||||
|
PASSED=$(jq '[.suites[].specs[].tests[] | select(.results[].status == "passed")] | length' test-results.json || echo "0")
|
||||||
|
FAILED=$(jq '[.suites[].specs[].tests[] | select(.results[].status == "failed")] | length' test-results.json || echo "0")
|
||||||
|
SKIPPED=$(jq '[.suites[].specs[].tests[] | select(.results[].status == "skipped")] | length' test-results.json || echo "0")
|
||||||
|
DURATION=$(jq '[.suites[].specs[].tests[].results[].duration] | add' test-results.json || echo "0")
|
||||||
|
|
||||||
|
echo "total=$TOTAL" >> $GITHUB_OUTPUT
|
||||||
|
echo "passed=$PASSED" >> $GITHUB_OUTPUT
|
||||||
|
echo "failed=$FAILED" >> $GITHUB_OUTPUT
|
||||||
|
echo "skipped=$SKIPPED" >> $GITHUB_OUTPUT
|
||||||
|
echo "duration=$DURATION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
echo "✅ Results: $PASSED passed, $FAILED failed, $SKIPPED skipped (${DURATION}ms total)"
|
||||||
|
else
|
||||||
|
echo "⚠️ test-results.json not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Send Test Results to Loki
|
||||||
|
if: always() && secrets.GRAFANA_LOKI_URL != ''
|
||||||
|
run: |
|
||||||
|
timestamp=$(date +%s)000000000
|
||||||
|
STATUS="${{ steps.playwright-run.outputs.test_exit_code == '0' && 'success' || 'failure' }}"
|
||||||
|
auth_header=""
|
||||||
|
if [ -n "${{ secrets.GRAFANA_LOKI_USERNAME }}" ]; then
|
||||||
|
auth_header="-u ${{ secrets.GRAFANA_LOKI_USERNAME }}:${{ secrets.GRAFANA_LOKI_PASSWORD }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -X POST "${{ secrets.GRAFANA_LOKI_URL }}/loki/api/v1/push" \
|
||||||
|
$auth_header \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"streams\": [{
|
||||||
|
\"stream\": {
|
||||||
|
\"job\": \"playwright-tests\",
|
||||||
|
\"browser\": \"${{ matrix.browser }}\",
|
||||||
|
\"workflow\": \"${{ github.workflow }}\",
|
||||||
|
\"branch\": \"${{ github.ref_name }}\",
|
||||||
|
\"commit\": \"${{ github.sha }}\",
|
||||||
|
\"run_id\": \"${{ github.run_id }}\",
|
||||||
|
\"status\": \"$STATUS\",
|
||||||
|
\"event\": \"test_complete\"
|
||||||
|
},
|
||||||
|
\"values\": [[\"$timestamp\", \"{\\\"total\\\": ${{ steps.parse-results.outputs.total || 0 }}, \\\"passed\\\": ${{ steps.parse-results.outputs.passed || 0 }}, \\\"failed\\\": ${{ steps.parse-results.outputs.failed || 0 }}, \\\"skipped\\\": ${{ steps.parse-results.outputs.skipped || 0 }}, \\\"duration_ms\\\": ${{ steps.parse-results.outputs.duration || 0 }}}\"]]
|
||||||
|
}]
|
||||||
|
}" || echo "⚠️ Failed to send results to Loki"
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-results-${{ matrix.browser }}
|
||||||
|
path: test-results/
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-report-${{ matrix.browser }}
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Comment PR with results
|
||||||
|
uses: daun/playwright-report-comment@v3
|
||||||
|
if: always() && github.event_name == 'pull_request'
|
||||||
|
with:
|
||||||
|
report-path: test-results.json
|
||||||
|
|
||||||
|
test-summary:
|
||||||
|
name: Test Summary
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
if: always()
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
|
||||||
|
- name: Generate summary
|
||||||
|
run: |
|
||||||
|
echo "## Playwright Test Results" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Tests completed across all browsers." >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "See artifacts for detailed reports and screenshots." >> $GITHUB_STEP_SUMMARY
|
||||||
63
docker-compose.loki.yml
Normal file
63
docker-compose.loki.yml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
# Local Grafana Loki + Grafana stack for testing Playwright integration
|
||||||
|
# Usage: docker-compose -f docker-compose.loki.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
loki:
|
||||||
|
image: grafana/loki:2.9.0
|
||||||
|
container_name: thrillwiki-loki
|
||||||
|
ports:
|
||||||
|
- "3100:3100"
|
||||||
|
volumes:
|
||||||
|
- ./loki-config.yml:/etc/loki/local-config.yaml
|
||||||
|
- loki-data:/loki
|
||||||
|
command: -config.file=/etc/loki/local-config.yaml
|
||||||
|
networks:
|
||||||
|
- loki-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:10.1.0
|
||||||
|
container_name: thrillwiki-grafana
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- GF_AUTH_ANONYMOUS_ENABLED=true
|
||||||
|
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||||
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
|
- GF_SERVER_ROOT_URL=http://localhost:3000
|
||||||
|
volumes:
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
- ./grafana-datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml
|
||||||
|
- ./monitoring/grafana-dashboard.json:/etc/grafana/provisioning/dashboards/playwright-dashboard.json
|
||||||
|
networks:
|
||||||
|
- loki-network
|
||||||
|
depends_on:
|
||||||
|
- loki
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Optional: Promtail for collecting logs from files
|
||||||
|
# promtail:
|
||||||
|
# image: grafana/promtail:2.9.0
|
||||||
|
# container_name: thrillwiki-promtail
|
||||||
|
# volumes:
|
||||||
|
# - ./promtail-config.yml:/etc/promtail/config.yml
|
||||||
|
# - ./test-results:/var/log/playwright:ro
|
||||||
|
# command: -config.file=/etc/promtail/config.yml
|
||||||
|
# networks:
|
||||||
|
# - loki-network
|
||||||
|
# depends_on:
|
||||||
|
# - loki
|
||||||
|
# restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
loki-data:
|
||||||
|
driver: local
|
||||||
|
grafana-data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
loki-network:
|
||||||
|
driver: bridge
|
||||||
45
grafana-datasources.yml
Normal file
45
grafana-datasources.yml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Grafana Data Source Provisioning
|
||||||
|
# Auto-configures Loki as a data source in Grafana
|
||||||
|
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Loki
|
||||||
|
type: loki
|
||||||
|
access: proxy
|
||||||
|
url: http://loki:3100
|
||||||
|
isDefault: true
|
||||||
|
editable: true
|
||||||
|
jsonData:
|
||||||
|
maxLines: 1000
|
||||||
|
derivedFields:
|
||||||
|
# Extract trace ID from logs for distributed tracing
|
||||||
|
- datasourceUid: tempo
|
||||||
|
matcherRegex: "traceId=(\\w+)"
|
||||||
|
name: TraceID
|
||||||
|
url: "$${__value.raw}"
|
||||||
|
# Extract request ID for correlation
|
||||||
|
- matcherRegex: "requestId=(\\w+)"
|
||||||
|
name: RequestID
|
||||||
|
url: "$${__value.raw}"
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
# Optional: Add Prometheus if you have metrics
|
||||||
|
# - name: Prometheus
|
||||||
|
# type: prometheus
|
||||||
|
# access: proxy
|
||||||
|
# url: http://prometheus:9090
|
||||||
|
# isDefault: false
|
||||||
|
# editable: true
|
||||||
|
# jsonData:
|
||||||
|
# timeInterval: 15s
|
||||||
|
# version: 1
|
||||||
|
|
||||||
|
# Optional: Add Tempo for distributed tracing
|
||||||
|
# - name: Tempo
|
||||||
|
# type: tempo
|
||||||
|
# access: proxy
|
||||||
|
# url: http://tempo:3200
|
||||||
|
# isDefault: false
|
||||||
|
# editable: true
|
||||||
|
# version: 1
|
||||||
112
loki-config.yml
Normal file
112
loki-config.yml
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Grafana Loki Configuration for Local Testing
|
||||||
|
# This is a basic configuration suitable for development and testing
|
||||||
|
|
||||||
|
auth_enabled: false
|
||||||
|
|
||||||
|
server:
|
||||||
|
http_listen_port: 3100
|
||||||
|
grpc_listen_port: 9096
|
||||||
|
log_level: info
|
||||||
|
|
||||||
|
common:
|
||||||
|
path_prefix: /loki
|
||||||
|
storage:
|
||||||
|
filesystem:
|
||||||
|
chunks_directory: /loki/chunks
|
||||||
|
rules_directory: /loki/rules
|
||||||
|
replication_factor: 1
|
||||||
|
ring:
|
||||||
|
instance_addr: 127.0.0.1
|
||||||
|
kvstore:
|
||||||
|
store: inmemory
|
||||||
|
|
||||||
|
# Configure the ingester for receiving logs
|
||||||
|
ingester:
|
||||||
|
lifecycler:
|
||||||
|
address: 127.0.0.1
|
||||||
|
ring:
|
||||||
|
kvstore:
|
||||||
|
store: inmemory
|
||||||
|
replication_factor: 1
|
||||||
|
final_sleep: 0s
|
||||||
|
chunk_idle_period: 5m
|
||||||
|
chunk_retain_period: 30s
|
||||||
|
max_chunk_age: 1h
|
||||||
|
chunk_encoding: snappy
|
||||||
|
|
||||||
|
# Schema configuration (defines how data is stored)
|
||||||
|
schema_config:
|
||||||
|
configs:
|
||||||
|
- from: 2020-10-24
|
||||||
|
store: boltdb-shipper
|
||||||
|
object_store: filesystem
|
||||||
|
schema: v11
|
||||||
|
index:
|
||||||
|
prefix: index_
|
||||||
|
period: 24h
|
||||||
|
|
||||||
|
# Storage configuration
|
||||||
|
storage_config:
|
||||||
|
boltdb_shipper:
|
||||||
|
active_index_directory: /loki/boltdb-shipper-active
|
||||||
|
cache_location: /loki/boltdb-shipper-cache
|
||||||
|
cache_ttl: 24h
|
||||||
|
shared_store: filesystem
|
||||||
|
filesystem:
|
||||||
|
directory: /loki/chunks
|
||||||
|
|
||||||
|
# Limits configuration
|
||||||
|
limits_config:
|
||||||
|
enforce_metric_name: false
|
||||||
|
reject_old_samples: true
|
||||||
|
reject_old_samples_max_age: 168h # 1 week
|
||||||
|
ingestion_rate_mb: 10
|
||||||
|
ingestion_burst_size_mb: 20
|
||||||
|
max_streams_per_user: 10000
|
||||||
|
max_query_length: 721h # 30 days
|
||||||
|
max_query_parallelism: 32
|
||||||
|
max_entries_limit_per_query: 5000
|
||||||
|
max_cache_freshness_per_query: 10m
|
||||||
|
|
||||||
|
# Chunk store configuration
|
||||||
|
chunk_store_config:
|
||||||
|
max_look_back_period: 0s
|
||||||
|
|
||||||
|
# Table manager configuration
|
||||||
|
table_manager:
|
||||||
|
retention_deletes_enabled: true
|
||||||
|
retention_period: 168h # 1 week retention for local testing
|
||||||
|
|
||||||
|
# Query range configuration
|
||||||
|
query_range:
|
||||||
|
align_queries_with_step: true
|
||||||
|
max_retries: 5
|
||||||
|
parallelise_shardable_queries: true
|
||||||
|
cache_results: true
|
||||||
|
|
||||||
|
# Compactor configuration
|
||||||
|
compactor:
|
||||||
|
working_directory: /loki/compactor
|
||||||
|
shared_store: filesystem
|
||||||
|
compaction_interval: 10m
|
||||||
|
retention_enabled: true
|
||||||
|
retention_delete_delay: 2h
|
||||||
|
retention_delete_worker_count: 150
|
||||||
|
|
||||||
|
# Ruler configuration (for alerting)
|
||||||
|
ruler:
|
||||||
|
storage:
|
||||||
|
type: local
|
||||||
|
local:
|
||||||
|
directory: /loki/rules
|
||||||
|
rule_path: /loki/rules-temp
|
||||||
|
alertmanager_url: http://localhost:9093
|
||||||
|
ring:
|
||||||
|
kvstore:
|
||||||
|
store: inmemory
|
||||||
|
enable_api: true
|
||||||
|
enable_alertmanager_v2: true
|
||||||
|
|
||||||
|
# Analytics configuration
|
||||||
|
analytics:
|
||||||
|
reporting_enabled: false
|
||||||
266
monitoring/grafana-dashboard.json
Normal file
266
monitoring/grafana-dashboard.json
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
{
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Playwright Test Execution Dashboard",
|
||||||
|
"tags": ["playwright", "testing", "e2e"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": {
|
||||||
|
"from": "now-24h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Test Execution Overview",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "x": 0, "y": 0, "w": 6, "h": 4 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "count_over_time({job=\"playwright-tests\", event=\"test_end\"}[$__range])",
|
||||||
|
"legendFormat": "Total Tests"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Pass Rate %",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "x": 6, "y": 0, "w": 6, "h": 4 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "(sum(count_over_time({job=\"playwright-tests\", status=\"passed\"}[$__range])) / sum(count_over_time({job=\"playwright-tests\", event=\"test_end\"}[$__range]))) * 100",
|
||||||
|
"legendFormat": "Pass Rate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "auto",
|
||||||
|
"unit": "percent"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "value": 0, "color": "red" },
|
||||||
|
{ "value": 80, "color": "yellow" },
|
||||||
|
{ "value": 95, "color": "green" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Failure Rate %",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "x": 12, "y": 0, "w": 6, "h": 4 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "(sum(count_over_time({job=\"playwright-tests\", status=\"failed\"}[$__range])) / sum(count_over_time({job=\"playwright-tests\", event=\"test_end\"}[$__range]))) * 100",
|
||||||
|
"legendFormat": "Failure Rate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "auto",
|
||||||
|
"unit": "percent"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "value": 0, "color": "green" },
|
||||||
|
{ "value": 5, "color": "yellow" },
|
||||||
|
{ "value": 20, "color": "red" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "Avg Test Duration",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "x": 18, "y": 0, "w": 6, "h": 4 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "avg_over_time({job=\"playwright-tests\", event=\"test_end\"} | json | unwrap duration_ms [$__range])",
|
||||||
|
"legendFormat": "Avg Duration"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"textMode": "auto",
|
||||||
|
"unit": "ms"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "Test Status Over Time",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum by (status) (count_over_time({job=\"playwright-tests\", event=\"test_end\"} | json [$__interval]))",
|
||||||
|
"legendFormat": "{{status}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {
|
||||||
|
"lineInterpolation": "smooth",
|
||||||
|
"fillOpacity": 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": { "id": "byName", "options": "passed" },
|
||||||
|
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": { "id": "byName", "options": "failed" },
|
||||||
|
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": { "id": "byName", "options": "skipped" },
|
||||||
|
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "yellow" } }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"title": "Browser Comparison",
|
||||||
|
"type": "bargauge",
|
||||||
|
"gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum by (browser) (count_over_time({job=\"playwright-tests\", status=\"passed\"} [$__range]))",
|
||||||
|
"legendFormat": "{{browser}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"displayMode": "gradient"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"title": "Test Duration Distribution",
|
||||||
|
"type": "histogram",
|
||||||
|
"gridPos": { "x": 0, "y": 12, "w": 12, "h": 8 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "{job=\"playwright-tests\", event=\"test_end\"} | json | unwrap duration_ms",
|
||||||
|
"legendFormat": "Duration"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"bucketOffset": 0,
|
||||||
|
"bucketSize": 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"title": "Top 10 Failing Tests",
|
||||||
|
"type": "bargauge",
|
||||||
|
"gridPos": { "x": 12, "y": 12, "w": 12, "h": 8 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "topk(10, sum by (test_name) (count_over_time({job=\"playwright-tests\", status=\"failed\"} | json [$__range])))",
|
||||||
|
"legendFormat": "{{test_name}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"displayMode": "gradient",
|
||||||
|
"showUnfilled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"title": "Recent Test Runs",
|
||||||
|
"type": "table",
|
||||||
|
"gridPos": { "x": 0, "y": 20, "w": 24, "h": 8 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "{job=\"playwright-tests\", event=\"test_end\"} | json",
|
||||||
|
"legendFormat": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"showHeader": true,
|
||||||
|
"sortBy": [{ "displayName": "Time", "desc": true }]
|
||||||
|
},
|
||||||
|
"transformations": [
|
||||||
|
{
|
||||||
|
"id": "organize",
|
||||||
|
"options": {
|
||||||
|
"excludeByName": {},
|
||||||
|
"indexByName": {
|
||||||
|
"Time": 0,
|
||||||
|
"test_name": 1,
|
||||||
|
"test_file": 2,
|
||||||
|
"browser": 3,
|
||||||
|
"status": 4,
|
||||||
|
"duration_ms": 5,
|
||||||
|
"branch": 6,
|
||||||
|
"commit": 7
|
||||||
|
},
|
||||||
|
"renameByName": {
|
||||||
|
"test_name": "Test Name",
|
||||||
|
"test_file": "File",
|
||||||
|
"browser": "Browser",
|
||||||
|
"status": "Status",
|
||||||
|
"duration_ms": "Duration (ms)",
|
||||||
|
"branch": "Branch",
|
||||||
|
"commit": "Commit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "limit",
|
||||||
|
"options": {
|
||||||
|
"limitField": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"title": "Slowest Tests (P95)",
|
||||||
|
"type": "table",
|
||||||
|
"gridPos": { "x": 0, "y": 28, "w": 12, "h": 6 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "topk(10, quantile_over_time(0.95, {job=\"playwright-tests\", event=\"test_end\"} | json | unwrap duration_ms by (test_name) [$__range]))",
|
||||||
|
"legendFormat": "{{test_name}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"title": "Flaky Tests Detection",
|
||||||
|
"type": "table",
|
||||||
|
"gridPos": { "x": 12, "y": 28, "w": 12, "h": 6 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "(count by (test_name) ({job=\"playwright-tests\", status=\"failed\"} | json) and count by (test_name) ({job=\"playwright-tests\", status=\"passed\"} | json))",
|
||||||
|
"legendFormat": "{{test_name}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Tests that have both passed and failed runs (potential flaky tests)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
166
monitoring/loki-alerts.yml
Normal file
166
monitoring/loki-alerts.yml
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# Grafana Loki Alert Rules for Playwright Tests
|
||||||
|
# Deploy this to AlertManager or Grafana Cloud
|
||||||
|
|
||||||
|
groups:
|
||||||
|
- name: playwright_test_alerts
|
||||||
|
interval: 1m
|
||||||
|
rules:
|
||||||
|
# Critical: All tests are failing
|
||||||
|
- alert: AllPlaywrightTestsFailing
|
||||||
|
expr: |
|
||||||
|
sum(rate({job="playwright-tests", status="passed"}[15m])) == 0
|
||||||
|
and
|
||||||
|
sum(rate({job="playwright-tests", event="test_end"}[15m])) > 0
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
team: qa
|
||||||
|
component: playwright
|
||||||
|
annotations:
|
||||||
|
summary: "All Playwright tests are failing"
|
||||||
|
description: "No passing tests detected in the last 15 minutes. Test count: {{ $value }}"
|
||||||
|
runbook_url: "https://wiki.internal/runbooks/playwright-all-tests-failing"
|
||||||
|
dashboard_url: "https://grafana.internal/d/playwright-dashboard"
|
||||||
|
|
||||||
|
# Warning: High failure rate
|
||||||
|
- alert: HighPlaywrightFailureRate
|
||||||
|
expr: |
|
||||||
|
(
|
||||||
|
sum(rate({job="playwright-tests", status="failed"}[30m]))
|
||||||
|
/
|
||||||
|
sum(rate({job="playwright-tests", event="test_end"}[30m]))
|
||||||
|
) > 0.20
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
team: qa
|
||||||
|
component: playwright
|
||||||
|
annotations:
|
||||||
|
summary: "High Playwright test failure rate detected"
|
||||||
|
description: "{{ $value | humanizePercentage }} of tests are failing over the last 30 minutes"
|
||||||
|
runbook_url: "https://wiki.internal/runbooks/playwright-high-failure-rate"
|
||||||
|
|
||||||
|
# Warning: Specific browser has high failure rate
|
||||||
|
- alert: BrowserSpecificFailures
|
||||||
|
expr: |
|
||||||
|
(
|
||||||
|
sum by (browser) (rate({job="playwright-tests", status="failed"}[30m]))
|
||||||
|
/
|
||||||
|
sum by (browser) (rate({job="playwright-tests", event="test_end"}[30m]))
|
||||||
|
) > 0.30
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
team: qa
|
||||||
|
component: playwright
|
||||||
|
annotations:
|
||||||
|
summary: "High failure rate in {{ $labels.browser }}"
|
||||||
|
description: "{{ $labels.browser }} browser has {{ $value | humanizePercentage }} failure rate"
|
||||||
|
|
||||||
|
# Warning: Slow test execution
|
||||||
|
- alert: SlowPlaywrightTests
|
||||||
|
expr: |
|
||||||
|
quantile_over_time(0.95,
|
||||||
|
{job="playwright-tests", event="test_end"} | json | unwrap duration_ms
|
||||||
|
[30m]) > 300000
|
||||||
|
for: 15m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
team: qa
|
||||||
|
component: playwright
|
||||||
|
annotations:
|
||||||
|
summary: "Playwright tests are running slowly"
|
||||||
|
description: "P95 test duration is {{ $value | humanizeDuration }} (threshold: 5 minutes)"
|
||||||
|
runbook_url: "https://wiki.internal/runbooks/playwright-slow-tests"
|
||||||
|
|
||||||
|
# Warning: Test suite timeout
|
||||||
|
- alert: PlaywrightSuiteTimeout
|
||||||
|
expr: |
|
||||||
|
{job="playwright-tests", event="test_suite_end"} | json | unwrap duration_ms > 3600000
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
team: qa
|
||||||
|
component: playwright
|
||||||
|
annotations:
|
||||||
|
summary: "Playwright test suite exceeded 1 hour"
|
||||||
|
description: "Test suite took {{ $value | humanizeDuration }} to complete"
|
||||||
|
|
||||||
|
# Info: No tests running (during business hours)
|
||||||
|
- alert: NoPlaywrightTestsRunning
|
||||||
|
expr: |
|
||||||
|
absent_over_time({job="playwright-tests", event="test_start"}[2h])
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: info
|
||||||
|
team: qa
|
||||||
|
component: playwright
|
||||||
|
annotations:
|
||||||
|
summary: "No Playwright tests have run recently"
|
||||||
|
description: "No test executions detected in the last 2 hours. CI/CD pipeline may be broken."
|
||||||
|
runbook_url: "https://wiki.internal/runbooks/playwright-no-tests"
|
||||||
|
|
||||||
|
# Warning: Flaky test detected
|
||||||
|
- alert: FlakyPlaywrightTest
|
||||||
|
expr: |
|
||||||
|
count by (test_name) (
|
||||||
|
{job="playwright-tests", status="failed", retry="1"} | json
|
||||||
|
) > 3
|
||||||
|
for: 1h
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
team: qa
|
||||||
|
component: playwright
|
||||||
|
annotations:
|
||||||
|
summary: "Flaky test detected: {{ $labels.test_name }}"
|
||||||
|
description: "Test '{{ $labels.test_name }}' has failed {{ $value }} times on retry in the last hour"
|
||||||
|
runbook_url: "https://wiki.internal/runbooks/playwright-flaky-tests"
|
||||||
|
|
||||||
|
# Critical: Test infrastructure failure
|
||||||
|
- alert: PlaywrightInfrastructureFailure
|
||||||
|
expr: |
|
||||||
|
count_over_time({job="playwright-tests", event="test_suite_start"}[30m]) == 0
|
||||||
|
and
|
||||||
|
count_over_time({job="playwright-tests"}[30m]) > 0
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
team: devops
|
||||||
|
component: playwright
|
||||||
|
annotations:
|
||||||
|
summary: "Playwright test infrastructure may be failing"
|
||||||
|
description: "Tests are attempting to run but test suite is not starting properly"
|
||||||
|
runbook_url: "https://wiki.internal/runbooks/playwright-infrastructure"
|
||||||
|
|
||||||
|
# Warning: High retry rate
|
||||||
|
- alert: HighPlaywrightRetryRate
|
||||||
|
expr: |
|
||||||
|
(
|
||||||
|
sum(rate({job="playwright-tests", retry!="0"}[30m]))
|
||||||
|
/
|
||||||
|
sum(rate({job="playwright-tests", event="test_end"}[30m]))
|
||||||
|
) > 0.15
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
team: qa
|
||||||
|
component: playwright
|
||||||
|
annotations:
|
||||||
|
summary: "High test retry rate detected"
|
||||||
|
description: "{{ $value | humanizePercentage }} of tests are being retried"
|
||||||
|
|
||||||
|
# Info: Test duration increasing
|
||||||
|
- alert: PlaywrightDurationIncreasing
|
||||||
|
expr: |
|
||||||
|
(
|
||||||
|
avg_over_time({job="playwright-tests", event="test_end"} | json | unwrap duration_ms [1h])
|
||||||
|
/
|
||||||
|
avg_over_time({job="playwright-tests", event="test_end"} | json | unwrap duration_ms [24h] offset 1h)
|
||||||
|
) > 1.5
|
||||||
|
for: 30m
|
||||||
|
labels:
|
||||||
|
severity: info
|
||||||
|
team: qa
|
||||||
|
component: playwright
|
||||||
|
annotations:
|
||||||
|
summary: "Playwright test duration is increasing"
|
||||||
|
description: "Average test duration has increased by {{ $value | humanizePercentage }} compared to previous day"
|
||||||
77
package-lock.json
generated
77
package-lock.json
generated
@@ -11,12 +11,14 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@faker-js/faker": "^10.1.0",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@marsidev/react-turnstile": "^1.3.1",
|
"@marsidev/react-turnstile": "^1.3.1",
|
||||||
"@mdxeditor/editor": "^3.47.0",
|
"@mdxeditor/editor": "^3.47.0",
|
||||||
"@novu/headless": "^2.6.6",
|
"@novu/headless": "^2.6.6",
|
||||||
"@novu/node": "^2.6.6",
|
"@novu/node": "^2.6.6",
|
||||||
"@novu/react": "^3.10.1",
|
"@novu/react": "^3.10.1",
|
||||||
|
"@playwright/test": "^1.56.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
@@ -1396,6 +1398,22 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@faker-js/faker": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fakerjs"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0",
|
||||||
|
"npm": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fastify/busboy": {
|
"node_modules/@fastify/busboy": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
|
||||||
@@ -2400,6 +2418,21 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.56.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
|
||||||
|
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.56.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/colors": {
|
"node_modules/@radix-ui/colors": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz",
|
||||||
@@ -10226,6 +10259,50 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.56.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
||||||
|
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.56.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.56.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
|
||||||
|
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|||||||
@@ -14,12 +14,14 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@faker-js/faker": "^10.1.0",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@marsidev/react-turnstile": "^1.3.1",
|
"@marsidev/react-turnstile": "^1.3.1",
|
||||||
"@mdxeditor/editor": "^3.47.0",
|
"@mdxeditor/editor": "^3.47.0",
|
||||||
"@novu/headless": "^2.6.6",
|
"@novu/headless": "^2.6.6",
|
||||||
"@novu/node": "^2.6.6",
|
"@novu/node": "^2.6.6",
|
||||||
"@novu/react": "^3.10.1",
|
"@novu/react": "^3.10.1",
|
||||||
|
"@playwright/test": "^1.56.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
|
|||||||
138
playwright.config.ts
Normal file
138
playwright.config.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright Configuration for ThrillWiki E2E Tests
|
||||||
|
*
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: [
|
||||||
|
['html'],
|
||||||
|
['list'],
|
||||||
|
['json', { outputFile: 'test-results.json' }],
|
||||||
|
// Grafana Loki reporter for centralized logging
|
||||||
|
['./tests/helpers/loki-reporter.ts', {
|
||||||
|
lokiUrl: process.env.GRAFANA_LOKI_URL,
|
||||||
|
username: process.env.GRAFANA_LOKI_USERNAME,
|
||||||
|
password: process.env.GRAFANA_LOKI_PASSWORD,
|
||||||
|
}]
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
baseURL: process.env.BASE_URL || 'http://localhost:8080',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
|
||||||
|
/* Screenshot on failure */
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
|
||||||
|
/* Video on failure */
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
|
||||||
|
/* Maximum time each action such as `click()` can take */
|
||||||
|
actionTimeout: 10000,
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Global timeout for each test */
|
||||||
|
timeout: 60000,
|
||||||
|
|
||||||
|
/* Global setup and teardown */
|
||||||
|
globalSetup: './tests/setup/global-setup.ts',
|
||||||
|
globalTeardown: './tests/setup/global-teardown.ts',
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
// Use authenticated state for most tests
|
||||||
|
storageState: '.auth/user.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Firefox'],
|
||||||
|
storageState: '.auth/user.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Safari'],
|
||||||
|
storageState: '.auth/user.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
{
|
||||||
|
name: 'Mobile Chrome',
|
||||||
|
use: {
|
||||||
|
...devices['Pixel 5'],
|
||||||
|
storageState: '.auth/user.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mobile Safari',
|
||||||
|
use: {
|
||||||
|
...devices['iPhone 12'],
|
||||||
|
storageState: '.auth/user.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Tests that require specific user roles */
|
||||||
|
{
|
||||||
|
name: 'moderator',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
storageState: '.auth/moderator.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'admin',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
storageState: '.auth/admin.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Authentication tests run without pre-authenticated state */
|
||||||
|
{
|
||||||
|
name: 'auth-tests',
|
||||||
|
testMatch: '**/auth/**/*.spec.ts',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
// No storageState for auth tests
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
url: 'http://localhost:8080',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120000,
|
||||||
|
},
|
||||||
|
});
|
||||||
175
scripts/test-loki-integration.sh
Normal file
175
scripts/test-loki-integration.sh
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${BLUE}🚀 Playwright + Grafana Loki Integration Test${NC}"
|
||||||
|
echo "=============================================="
|
||||||
|
|
||||||
|
# Check if Docker is running
|
||||||
|
if ! docker info > /dev/null 2>&1; then
|
||||||
|
echo -e "${RED}❌ Docker is not running. Please start Docker first.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n${BLUE}📦 Starting local Loki stack...${NC}"
|
||||||
|
if [ -f "docker-compose.loki.yml" ]; then
|
||||||
|
docker-compose -f docker-compose.loki.yml up -d
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠️ docker-compose.loki.yml not found. Creating basic Loki setup...${NC}"
|
||||||
|
|
||||||
|
# Create temporary Loki config
|
||||||
|
cat > /tmp/loki-config.yml << 'EOF'
|
||||||
|
auth_enabled: false
|
||||||
|
|
||||||
|
server:
|
||||||
|
http_listen_port: 3100
|
||||||
|
|
||||||
|
ingester:
|
||||||
|
lifecycler:
|
||||||
|
address: 127.0.0.1
|
||||||
|
ring:
|
||||||
|
kvstore:
|
||||||
|
store: inmemory
|
||||||
|
replication_factor: 1
|
||||||
|
chunk_idle_period: 3m
|
||||||
|
chunk_retain_period: 1m
|
||||||
|
|
||||||
|
schema_config:
|
||||||
|
configs:
|
||||||
|
- from: 2020-10-24
|
||||||
|
store: boltdb
|
||||||
|
object_store: filesystem
|
||||||
|
schema: v11
|
||||||
|
index:
|
||||||
|
prefix: index_
|
||||||
|
period: 168h
|
||||||
|
|
||||||
|
storage_config:
|
||||||
|
boltdb:
|
||||||
|
directory: /tmp/loki/index
|
||||||
|
filesystem:
|
||||||
|
directory: /tmp/loki/chunks
|
||||||
|
|
||||||
|
limits_config:
|
||||||
|
enforce_metric_name: false
|
||||||
|
reject_old_samples: true
|
||||||
|
reject_old_samples_max_age: 168h
|
||||||
|
|
||||||
|
chunk_store_config:
|
||||||
|
max_look_back_period: 0s
|
||||||
|
|
||||||
|
table_manager:
|
||||||
|
retention_deletes_enabled: false
|
||||||
|
retention_period: 0s
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Start Loki container
|
||||||
|
docker run -d \
|
||||||
|
--name loki-test \
|
||||||
|
-p 3100:3100 \
|
||||||
|
-v /tmp/loki-config.yml:/etc/loki/local-config.yaml \
|
||||||
|
grafana/loki:2.9.0 \
|
||||||
|
-config.file=/etc/loki/local-config.yaml
|
||||||
|
|
||||||
|
# Start Grafana container
|
||||||
|
docker run -d \
|
||||||
|
--name grafana-test \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e "GF_AUTH_ANONYMOUS_ENABLED=true" \
|
||||||
|
-e "GF_AUTH_ANONYMOUS_ORG_ROLE=Admin" \
|
||||||
|
grafana/grafana:10.1.0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait for Loki to be ready
|
||||||
|
echo -e "\n${YELLOW}⏳ Waiting for Loki to start...${NC}"
|
||||||
|
max_attempts=30
|
||||||
|
attempt=0
|
||||||
|
until curl -s http://localhost:3100/ready | grep -q "ready" || [ $attempt -eq $max_attempts ]; do
|
||||||
|
sleep 2
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
echo -n "."
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ $attempt -eq $max_attempts ]; then
|
||||||
|
echo -e "${RED}❌ Loki failed to start within 60 seconds${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✅ Loki is ready${NC}"
|
||||||
|
|
||||||
|
# Export environment variables
|
||||||
|
export GRAFANA_LOKI_URL="http://localhost:3100"
|
||||||
|
export GRAFANA_LOKI_USERNAME=""
|
||||||
|
export GRAFANA_LOKI_PASSWORD=""
|
||||||
|
|
||||||
|
echo -e "\n${BLUE}🧪 Running a test Playwright test...${NC}"
|
||||||
|
# Check if tests directory exists
|
||||||
|
if [ -d "tests/e2e" ]; then
|
||||||
|
npx playwright test tests/e2e/auth/login.spec.ts --project=chromium --reporter=./tests/helpers/loki-reporter.ts 2>&1 || true
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠️ No test files found. Skipping test execution.${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait a moment for logs to be ingested
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
echo -e "\n${BLUE}🔍 Querying Loki for test logs...${NC}"
|
||||||
|
start_time=$(date -u -d '5 minutes ago' +%s)000000000
|
||||||
|
end_time=$(date -u +%s)000000000
|
||||||
|
|
||||||
|
response=$(curl -s -G "http://localhost:3100/loki/api/v1/query_range" \
|
||||||
|
--data-urlencode 'query={job="playwright-tests"}' \
|
||||||
|
--data-urlencode "start=$start_time" \
|
||||||
|
--data-urlencode "end=$end_time")
|
||||||
|
|
||||||
|
# Check if we got results
|
||||||
|
result_count=$(echo "$response" | jq '.data.result | length')
|
||||||
|
|
||||||
|
if [ "$result_count" -gt 0 ]; then
|
||||||
|
echo -e "${GREEN}✅ Found $result_count log streams in Loki${NC}"
|
||||||
|
echo -e "\n${BLUE}Sample logs:${NC}"
|
||||||
|
echo "$response" | jq -r '.data.result[0].values[0:3][] | .[1]' 2>/dev/null || echo "No log content available"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠️ No logs found in Loki. This might be expected if no tests ran.${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Display useful queries
|
||||||
|
echo -e "\n${BLUE}📊 Useful LogQL Queries:${NC}"
|
||||||
|
echo "------------------------------------"
|
||||||
|
echo "All test logs:"
|
||||||
|
echo ' {job="playwright-tests"}'
|
||||||
|
echo ""
|
||||||
|
echo "Failed tests only:"
|
||||||
|
echo ' {job="playwright-tests", status="failed"}'
|
||||||
|
echo ""
|
||||||
|
echo "Tests by browser:"
|
||||||
|
echo ' {job="playwright-tests", browser="chromium"}'
|
||||||
|
echo ""
|
||||||
|
echo "Test duration stats:"
|
||||||
|
echo ' quantile_over_time(0.95, {job="playwright-tests"} | json | unwrap duration_ms [1h])'
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Open Grafana
|
||||||
|
echo -e "\n${GREEN}🌐 Grafana is available at: http://localhost:3000${NC}"
|
||||||
|
echo -e "${BLUE} Default credentials: admin / admin${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}📖 To add Loki as a data source in Grafana:${NC}"
|
||||||
|
echo " 1. Go to Configuration > Data Sources"
|
||||||
|
echo " 2. Add Loki with URL: http://localhost:3100"
|
||||||
|
echo " 3. Import the dashboard from: monitoring/grafana-dashboard.json"
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✅ Test complete!${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}To stop the containers:${NC}"
|
||||||
|
echo " docker stop loki-test grafana-test"
|
||||||
|
echo " docker rm loki-test grafana-test"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}To view logs in real-time:${NC}"
|
||||||
|
echo " docker logs -f loki-test"
|
||||||
237
tests/README.md
Normal file
237
tests/README.md
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# ThrillWiki E2E Testing with Playwright
|
||||||
|
|
||||||
|
This directory contains comprehensive end-to-end tests for ThrillWiki using Playwright.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
These tests replace the problematic backend integration tests with proper browser-based E2E tests that:
|
||||||
|
- ✅ Test the actual user experience
|
||||||
|
- ✅ Respect security policies (RLS)
|
||||||
|
- ✅ Validate the complete submission → moderation → approval flow
|
||||||
|
- ✅ Run in real browsers (Chromium, Firefox, WebKit)
|
||||||
|
- ✅ Support parallel execution for speed
|
||||||
|
- ✅ Capture videos and screenshots on failure
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── e2e/ # End-to-end test specs
|
||||||
|
│ ├── auth/ # Authentication tests
|
||||||
|
│ ├── submission/ # Entity submission tests
|
||||||
|
│ ├── moderation/ # Moderation queue tests
|
||||||
|
│ ├── versioning/ # Version history tests
|
||||||
|
│ ├── admin/ # Admin panel tests
|
||||||
|
│ └── public/ # Public browsing tests
|
||||||
|
├── fixtures/ # Test fixtures and helpers
|
||||||
|
│ ├── auth.ts # Authentication helpers
|
||||||
|
│ ├── database.ts # Direct DB access for setup/teardown
|
||||||
|
│ └── test-data.ts # Test data generators
|
||||||
|
├── helpers/ # Utility functions
|
||||||
|
│ └── page-objects/ # Page Object Models
|
||||||
|
├── setup/ # Global setup and teardown
|
||||||
|
│ ├── global-setup.ts # Runs before all tests
|
||||||
|
│ └── global-teardown.ts # Runs after all tests
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Node.js** and **npm** installed
|
||||||
|
2. **Supabase service role key** for test setup/teardown:
|
||||||
|
```bash
|
||||||
|
export SUPABASE_SERVICE_ROLE_KEY="your-service-role-key"
|
||||||
|
```
|
||||||
|
3. **Test users** (optional, will be auto-created):
|
||||||
|
```bash
|
||||||
|
export TEST_USER_EMAIL="test-user@thrillwiki.test"
|
||||||
|
export TEST_USER_PASSWORD="TestUser123!"
|
||||||
|
export TEST_MODERATOR_EMAIL="test-moderator@thrillwiki.test"
|
||||||
|
export TEST_MODERATOR_PASSWORD="TestModerator123!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Playwright and browsers
|
||||||
|
npm install
|
||||||
|
npx playwright install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### All tests
|
||||||
|
```bash
|
||||||
|
npx playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Specific test file
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/e2e/auth/login.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Specific test suite
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/e2e/submission/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run in headed mode (see browser)
|
||||||
|
```bash
|
||||||
|
npx playwright test --headed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run in UI mode (interactive)
|
||||||
|
```bash
|
||||||
|
npx playwright test --ui
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run with specific browser
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium
|
||||||
|
npx playwright test --project=firefox
|
||||||
|
npx playwright test --project=webkit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run tests with specific user role
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=moderator
|
||||||
|
npx playwright test --project=admin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging Tests
|
||||||
|
|
||||||
|
### Debug mode
|
||||||
|
```bash
|
||||||
|
npx playwright test --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Show test report
|
||||||
|
```bash
|
||||||
|
npx playwright show-report
|
||||||
|
```
|
||||||
|
|
||||||
|
### View trace for failed test
|
||||||
|
```bash
|
||||||
|
npx playwright show-trace trace.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing New Tests
|
||||||
|
|
||||||
|
### 1. Create a test file
|
||||||
|
```typescript
|
||||||
|
// tests/e2e/feature/my-feature.spec.ts
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('My Feature', () => {
|
||||||
|
test('should do something', async ({ page }) => {
|
||||||
|
await page.goto('/my-feature');
|
||||||
|
await expect(page.getByText('Hello')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Use Page Objects (recommended)
|
||||||
|
```typescript
|
||||||
|
import { MyFeaturePage } from '../../helpers/page-objects/MyFeaturePage';
|
||||||
|
|
||||||
|
test('should use page object', async ({ page }) => {
|
||||||
|
const myFeature = new MyFeaturePage(page);
|
||||||
|
await myFeature.goto();
|
||||||
|
await myFeature.doSomething();
|
||||||
|
await myFeature.expectSuccess();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Use test data generators
|
||||||
|
```typescript
|
||||||
|
import { generateParkData } from '../../fixtures/test-data';
|
||||||
|
|
||||||
|
test('should create park', async ({ page }) => {
|
||||||
|
const parkData = generateParkData({ name: 'Custom Park' });
|
||||||
|
// Use parkData in your test
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Clean up test data
|
||||||
|
```typescript
|
||||||
|
import { cleanupTestData } from '../../fixtures/database';
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await cleanupTestData();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Data Management
|
||||||
|
|
||||||
|
All test data is automatically marked with `is_test_data: true` and cleaned up after tests run.
|
||||||
|
|
||||||
|
### Manual cleanup
|
||||||
|
If tests fail and leave data behind:
|
||||||
|
```typescript
|
||||||
|
import { cleanupTestData } from './fixtures/database';
|
||||||
|
await cleanupTestData();
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the "Emergency Cleanup" button in the admin UI at `/admin/settings`.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Tests use pre-authenticated browser contexts to avoid logging in for every test:
|
||||||
|
|
||||||
|
- `.auth/user.json` - Regular user
|
||||||
|
- `.auth/moderator.json` - Moderator with AAL2
|
||||||
|
- `.auth/admin.json` - Admin with AAL2
|
||||||
|
- `.auth/superuser.json` - Superuser with AAL2
|
||||||
|
|
||||||
|
These are created automatically during global setup.
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
Tests run automatically on GitHub Actions:
|
||||||
|
- On every pull request
|
||||||
|
- On push to main branch
|
||||||
|
- Results posted as PR comments
|
||||||
|
|
||||||
|
See `.github/workflows/playwright.yml` for configuration.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use Page Objects** - Encapsulate page interactions
|
||||||
|
2. **Mark test data** - Always set `is_test_data: true`
|
||||||
|
3. **Clean up** - Use `test.afterAll()` or `test.afterEach()`
|
||||||
|
4. **Use fixtures** - Reuse auth, database, and test data helpers
|
||||||
|
5. **Test user flows** - Not individual functions
|
||||||
|
6. **Avoid hardcoded waits** - Use `waitForLoadState()` or `waitForSelector()`
|
||||||
|
7. **Take screenshots** - On failure (automatic)
|
||||||
|
8. **Parallelize** - Tests run in parallel by default
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "Service role key not configured"
|
||||||
|
Set the `SUPABASE_SERVICE_ROLE_KEY` environment variable.
|
||||||
|
|
||||||
|
### "Test data not cleaned up"
|
||||||
|
Run `npx playwright test --project=cleanup` or use the admin UI emergency cleanup button.
|
||||||
|
|
||||||
|
### "Lock timeout"
|
||||||
|
Some tests involving moderation locks may take longer. Increase timeout:
|
||||||
|
```typescript
|
||||||
|
test('my slow test', async ({ page }) => {
|
||||||
|
test.setTimeout(120000); // 2 minutes
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Playwright Documentation](https://playwright.dev/)
|
||||||
|
- [Playwright Best Practices](https://playwright.dev/docs/best-practices)
|
||||||
|
- [ThrillWiki Testing Guide](../docs/TESTING_GUIDE.md)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For questions or issues with tests, check:
|
||||||
|
1. This README
|
||||||
|
2. Playwright docs
|
||||||
|
3. Test failure screenshots/videos in `test-results/`
|
||||||
|
4. GitHub Actions logs for CI failures
|
||||||
134
tests/e2e/auth/login.spec.ts
Normal file
134
tests/e2e/auth/login.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Login E2E Tests
|
||||||
|
*
|
||||||
|
* Tests authentication flow, session persistence, and error handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { LoginPage } from '../../helpers/page-objects/LoginPage';
|
||||||
|
import { getTestUserCredentials, logout } from '../../fixtures/auth';
|
||||||
|
|
||||||
|
// These tests run without pre-authenticated state
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test.describe('Login Flow', () => {
|
||||||
|
let loginPage: LoginPage;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
loginPage = new LoginPage(page);
|
||||||
|
await loginPage.goto();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should login successfully with valid credentials', async ({ page }) => {
|
||||||
|
const { email, password } = getTestUserCredentials('user');
|
||||||
|
|
||||||
|
await loginPage.login(email, password);
|
||||||
|
await loginPage.expectLoginSuccess();
|
||||||
|
|
||||||
|
// Verify we're redirected to homepage
|
||||||
|
await expect(page).toHaveURL('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show error with invalid password', async ({ page }) => {
|
||||||
|
const { email } = getTestUserCredentials('user');
|
||||||
|
|
||||||
|
await loginPage.login(email, 'wrongpassword123');
|
||||||
|
await loginPage.expectLoginError();
|
||||||
|
|
||||||
|
// Verify we're still on auth page
|
||||||
|
await expect(page).toHaveURL(/\/auth/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show error with non-existent email', async ({ page }) => {
|
||||||
|
await loginPage.login('nonexistent@example.com', 'password123');
|
||||||
|
await loginPage.expectLoginError();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should persist session after page refresh', async ({ page }) => {
|
||||||
|
const { email, password } = getTestUserCredentials('user');
|
||||||
|
|
||||||
|
// Login
|
||||||
|
await loginPage.login(email, password);
|
||||||
|
await loginPage.expectLoginSuccess();
|
||||||
|
|
||||||
|
// Reload page
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Should still be logged in (not redirected to /auth)
|
||||||
|
await expect(page).not.toHaveURL(/\/auth/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should clear session on logout', async ({ page }) => {
|
||||||
|
const { email, password } = getTestUserCredentials('user');
|
||||||
|
|
||||||
|
// Login
|
||||||
|
await loginPage.login(email, password);
|
||||||
|
await loginPage.expectLoginSuccess();
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
await page.click('button:has-text("Logout")').or(page.click('[data-testid="logout"]'));
|
||||||
|
|
||||||
|
// Should be redirected to auth or homepage
|
||||||
|
// And trying to access protected route should redirect to auth
|
||||||
|
await page.goto('/moderation/queue');
|
||||||
|
await expect(page).toHaveURL(/\/auth/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate email format', async ({ page }) => {
|
||||||
|
await loginPage.login('invalid-email', 'password123');
|
||||||
|
|
||||||
|
// Should show validation error
|
||||||
|
await expect(page.getByText(/invalid.*email/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should require password', async ({ page }) => {
|
||||||
|
const { email } = getTestUserCredentials('user');
|
||||||
|
|
||||||
|
await page.fill('input[type="email"]', email);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
// Should show validation error
|
||||||
|
await expect(page.getByText(/password.*required/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Role-Based Access', () => {
|
||||||
|
test('moderator can access moderation queue', async ({ browser }) => {
|
||||||
|
const context = await browser.newContext({ storageState: '.auth/moderator.json' });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto('/moderation/queue');
|
||||||
|
|
||||||
|
// Should not be redirected
|
||||||
|
await expect(page).toHaveURL(/\/moderation\/queue/);
|
||||||
|
|
||||||
|
// Page should load successfully
|
||||||
|
await expect(page.getByText(/moderation.*queue/i)).toBeVisible();
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regular user cannot access moderation queue', async ({ browser }) => {
|
||||||
|
const context = await browser.newContext({ storageState: '.auth/user.json' });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto('/moderation/queue');
|
||||||
|
|
||||||
|
// Should be redirected or see access denied
|
||||||
|
await expect(page.getByText(/access denied/i).or(page.getByText(/not authorized/i))).toBeVisible();
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can access admin panel', async ({ browser }) => {
|
||||||
|
const context = await browser.newContext({ storageState: '.auth/admin.json' });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
|
||||||
|
// Should not be redirected
|
||||||
|
await expect(page).toHaveURL(/\/admin/);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
139
tests/e2e/moderation/approval-flow.spec.ts
Normal file
139
tests/e2e/moderation/approval-flow.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Moderation Approval Flow E2E Tests
|
||||||
|
*
|
||||||
|
* Tests the complete submission approval process.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { ModerationQueuePage } from '../../helpers/page-objects/ModerationQueuePage';
|
||||||
|
import { ParkCreationPage } from '../../helpers/page-objects/ParkCreationPage';
|
||||||
|
import { generateParkData, generateTestId } from '../../fixtures/test-data';
|
||||||
|
import { queryDatabase, cleanupTestData, waitForVersion } from '../../fixtures/database';
|
||||||
|
|
||||||
|
test.describe('Submission Approval Flow', () => {
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await cleanupTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should approve park submission and create entity', async ({ browser }) => {
|
||||||
|
// Step 1: Create submission as regular user
|
||||||
|
const userContext = await browser.newContext({ storageState: '.auth/user.json' });
|
||||||
|
const userPage = await userContext.newPage();
|
||||||
|
|
||||||
|
const parkData = generateParkData({
|
||||||
|
name: `Test Park ${generateTestId()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parkCreationPage = new ParkCreationPage(userPage);
|
||||||
|
await parkCreationPage.goto();
|
||||||
|
await parkCreationPage.fillBasicInfo(parkData.name, parkData.description);
|
||||||
|
await parkCreationPage.submitForm();
|
||||||
|
await parkCreationPage.expectSuccess();
|
||||||
|
|
||||||
|
await userContext.close();
|
||||||
|
|
||||||
|
// Step 2: Approve as moderator
|
||||||
|
const modContext = await browser.newContext({ storageState: '.auth/moderator.json' });
|
||||||
|
const modPage = await modContext.newPage();
|
||||||
|
|
||||||
|
const moderationPage = new ModerationQueuePage(modPage);
|
||||||
|
await moderationPage.goto();
|
||||||
|
|
||||||
|
// Find the submission
|
||||||
|
await moderationPage.expectSubmissionVisible(parkData.name);
|
||||||
|
|
||||||
|
// Claim it
|
||||||
|
await moderationPage.claimSubmission(0);
|
||||||
|
|
||||||
|
// Approve it
|
||||||
|
await moderationPage.approveSubmission('Looks good!');
|
||||||
|
|
||||||
|
// Step 3: Verify entity created in database
|
||||||
|
await modPage.waitForTimeout(2000); // Give DB time to process
|
||||||
|
|
||||||
|
const parks = await queryDatabase('parks', (qb) =>
|
||||||
|
qb.select('*').eq('name', parkData.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parks).toHaveLength(1);
|
||||||
|
expect(parks[0].is_test_data).toBe(true);
|
||||||
|
|
||||||
|
// Step 4: Verify version created
|
||||||
|
const versions = await queryDatabase('park_versions', (qb) =>
|
||||||
|
qb.select('*').eq('park_id', parks[0].id).eq('version_number', 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(versions).toHaveLength(1);
|
||||||
|
expect(versions[0].change_type).toBe('created');
|
||||||
|
|
||||||
|
// Step 5: Verify submission marked as approved
|
||||||
|
const submissions = await queryDatabase('content_submissions', (qb) =>
|
||||||
|
qb.select('*').eq('entity_type', 'park').contains('submission_data', { name: parkData.name })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(submissions[0].status).toBe('approved');
|
||||||
|
expect(submissions[0].approved_by).toBeTruthy();
|
||||||
|
expect(submissions[0].approved_at).toBeTruthy();
|
||||||
|
|
||||||
|
// Step 6: Verify lock released
|
||||||
|
expect(submissions[0].assigned_to).toBeNull();
|
||||||
|
expect(submissions[0].locked_until).toBeNull();
|
||||||
|
|
||||||
|
await modContext.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show change comparison for edits', async ({ browser }) => {
|
||||||
|
// This test would require:
|
||||||
|
// 1. Creating and approving a park
|
||||||
|
// 2. Editing the park
|
||||||
|
// 3. Viewing the edit in moderation queue
|
||||||
|
// 4. Verifying change comparison displays
|
||||||
|
// Left as TODO - requires more complex setup
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should send notification to submitter on approval', async ({ browser }) => {
|
||||||
|
// This test would verify that Novu notification is sent
|
||||||
|
// Left as TODO - requires Novu testing setup
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should prevent approval without lock', async ({ browser }) => {
|
||||||
|
// Create submission as user
|
||||||
|
const userContext = await browser.newContext({ storageState: '.auth/user.json' });
|
||||||
|
const userPage = await userContext.newPage();
|
||||||
|
|
||||||
|
const parkData = generateParkData({
|
||||||
|
name: `Test Park ${generateTestId()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parkCreationPage = new ParkCreationPage(userPage);
|
||||||
|
await parkCreationPage.goto();
|
||||||
|
await parkCreationPage.fillBasicInfo(parkData.name, parkData.description);
|
||||||
|
await parkCreationPage.submitForm();
|
||||||
|
await parkCreationPage.expectSuccess();
|
||||||
|
|
||||||
|
await userContext.close();
|
||||||
|
|
||||||
|
// Try to approve as moderator WITHOUT claiming
|
||||||
|
const modContext = await browser.newContext({ storageState: '.auth/moderator.json' });
|
||||||
|
const modPage = await modContext.newPage();
|
||||||
|
|
||||||
|
const moderationPage = new ModerationQueuePage(modPage);
|
||||||
|
await moderationPage.goto();
|
||||||
|
|
||||||
|
// Approve button should be disabled or not visible
|
||||||
|
const approveButton = modPage.locator('button:has-text("Approve")').first();
|
||||||
|
await expect(approveButton).toBeDisabled();
|
||||||
|
|
||||||
|
await modContext.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Bulk Approval', () => {
|
||||||
|
test('should approve all items in submission', async ({ browser }) => {
|
||||||
|
// TODO: Test approving all submission items at once
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow selective item approval', async ({ browser }) => {
|
||||||
|
// TODO: Test approving only specific items from a submission
|
||||||
|
});
|
||||||
|
});
|
||||||
138
tests/e2e/submission/park-creation.spec.ts
Normal file
138
tests/e2e/submission/park-creation.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Park Creation E2E Tests
|
||||||
|
*
|
||||||
|
* Tests the complete park submission flow through the UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { ParkCreationPage } from '../../helpers/page-objects/ParkCreationPage';
|
||||||
|
import { generateParkData, generateTestId } from '../../fixtures/test-data';
|
||||||
|
import { queryDatabase, cleanupTestData } from '../../fixtures/database';
|
||||||
|
|
||||||
|
test.describe('Park Creation Flow', () => {
|
||||||
|
let parkCreationPage: ParkCreationPage;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
parkCreationPage = new ParkCreationPage(page);
|
||||||
|
await parkCreationPage.goto();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
// Clean up test data
|
||||||
|
await cleanupTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create park submission successfully', async ({ page }) => {
|
||||||
|
const parkData = generateParkData({
|
||||||
|
name: `Test Park ${generateTestId()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fill form
|
||||||
|
await parkCreationPage.fillBasicInfo(parkData.name, parkData.description);
|
||||||
|
await parkCreationPage.selectParkType(parkData.park_type);
|
||||||
|
await parkCreationPage.selectStatus(parkData.status);
|
||||||
|
await parkCreationPage.setOpeningDate(parkData.opened_date);
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
await parkCreationPage.submitForm();
|
||||||
|
await parkCreationPage.expectSuccess();
|
||||||
|
|
||||||
|
// Verify submission created in database
|
||||||
|
const submissions = await queryDatabase('content_submissions', (qb) =>
|
||||||
|
qb.select('*').eq('entity_type', 'park').eq('is_test_data', true).order('created_at', { ascending: false }).limit(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(submissions).toHaveLength(1);
|
||||||
|
expect(submissions[0].status).toBe('pending');
|
||||||
|
|
||||||
|
// Verify NO park created yet (should be in moderation queue)
|
||||||
|
const parks = await queryDatabase('parks', (qb) =>
|
||||||
|
qb.select('*').eq('slug', parkData.slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parks).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate required fields', async ({ page }) => {
|
||||||
|
// Try to submit empty form
|
||||||
|
await parkCreationPage.submitForm();
|
||||||
|
|
||||||
|
// Should show validation errors
|
||||||
|
await parkCreationPage.expectValidationError('Name is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should auto-generate slug from name', async ({ page }) => {
|
||||||
|
const parkName = `Amazing Theme Park ${generateTestId()}`;
|
||||||
|
|
||||||
|
await page.fill('input[name="name"]', parkName);
|
||||||
|
|
||||||
|
// Wait for slug to auto-generate
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const slugValue = await page.inputValue('input[name="slug"]');
|
||||||
|
expect(slugValue).toContain('amazing-theme-park');
|
||||||
|
expect(slugValue).not.toContain(' '); // No spaces
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should support custom date precision', async ({ page }) => {
|
||||||
|
const parkData = generateParkData({
|
||||||
|
name: `Test Park ${generateTestId()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await parkCreationPage.fillBasicInfo(parkData.name, parkData.description);
|
||||||
|
await parkCreationPage.setOpeningDate('2020-01-01', 'year');
|
||||||
|
|
||||||
|
await parkCreationPage.submitForm();
|
||||||
|
await parkCreationPage.expectSuccess();
|
||||||
|
|
||||||
|
// Verify date precision in submission
|
||||||
|
const submissions = await queryDatabase('content_submissions', (qb) =>
|
||||||
|
qb.select('submission_data').eq('entity_type', 'park').eq('is_test_data', true).order('created_at', { ascending: false }).limit(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
const submissionData = submissions[0].submission_data;
|
||||||
|
expect(submissionData.opened_date_precision).toBe('year');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display submission in user profile', async ({ page }) => {
|
||||||
|
const parkData = generateParkData({
|
||||||
|
name: `Test Park ${generateTestId()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create submission
|
||||||
|
await parkCreationPage.fillBasicInfo(parkData.name, parkData.description);
|
||||||
|
await parkCreationPage.submitForm();
|
||||||
|
await parkCreationPage.expectSuccess();
|
||||||
|
|
||||||
|
// Navigate to user profile
|
||||||
|
await page.click('[data-testid="user-menu"]').or(page.click('button:has-text("Profile")'));
|
||||||
|
await page.click('text=My Submissions');
|
||||||
|
|
||||||
|
// Verify submission appears
|
||||||
|
await expect(page.getByText(parkData.name)).toBeVisible();
|
||||||
|
await expect(page.getByText(/pending/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Park Form Validation', () => {
|
||||||
|
let parkCreationPage: ParkCreationPage;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
parkCreationPage = new ParkCreationPage(page);
|
||||||
|
await parkCreationPage.goto();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should enforce minimum description length', async ({ page }) => {
|
||||||
|
await page.fill('input[name="name"]', 'Test Park');
|
||||||
|
await page.fill('textarea[name="description"]', 'Too short');
|
||||||
|
|
||||||
|
await parkCreationPage.submitForm();
|
||||||
|
|
||||||
|
await expect(page.getByText(/description.*too short/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should prevent duplicate slugs', async ({ page }) => {
|
||||||
|
// This test would require creating a park first, then trying to create another with same slug
|
||||||
|
// Left as TODO - requires more complex setup
|
||||||
|
});
|
||||||
|
});
|
||||||
123
tests/fixtures/auth.ts
vendored
Normal file
123
tests/fixtures/auth.ts
vendored
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Authentication Fixtures for Playwright Tests
|
||||||
|
*
|
||||||
|
* Manages authentication state for different user roles.
|
||||||
|
* Creates reusable auth states to avoid logging in for every test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { chromium, type FullConfig } from '@playwright/test';
|
||||||
|
import { setupTestUser, supabase } from './database';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const TEST_USERS = {
|
||||||
|
user: {
|
||||||
|
email: process.env.TEST_USER_EMAIL || 'test-user@thrillwiki.test',
|
||||||
|
password: process.env.TEST_USER_PASSWORD || 'TestUser123!',
|
||||||
|
role: 'user' as const,
|
||||||
|
},
|
||||||
|
moderator: {
|
||||||
|
email: process.env.TEST_MODERATOR_EMAIL || 'test-moderator@thrillwiki.test',
|
||||||
|
password: process.env.TEST_MODERATOR_PASSWORD || 'TestModerator123!',
|
||||||
|
role: 'moderator' as const,
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
email: process.env.TEST_ADMIN_EMAIL || 'test-admin@thrillwiki.test',
|
||||||
|
password: process.env.TEST_ADMIN_PASSWORD || 'TestAdmin123!',
|
||||||
|
role: 'admin' as const,
|
||||||
|
},
|
||||||
|
superuser: {
|
||||||
|
email: process.env.TEST_SUPERUSER_EMAIL || 'test-superuser@thrillwiki.test',
|
||||||
|
password: process.env.TEST_SUPERUSER_PASSWORD || 'TestSuperuser123!',
|
||||||
|
role: 'superuser' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup authentication states for all test users
|
||||||
|
*/
|
||||||
|
export async function setupAuthStates(config: FullConfig): Promise<void> {
|
||||||
|
const baseURL = config.projects[0].use.baseURL || 'http://localhost:8080';
|
||||||
|
|
||||||
|
// Ensure .auth directory exists
|
||||||
|
const authDir = path.join(process.cwd(), '.auth');
|
||||||
|
if (!fs.existsSync(authDir)) {
|
||||||
|
fs.mkdirSync(authDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
|
||||||
|
for (const [roleName, userData] of Object.entries(TEST_USERS)) {
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create test user if doesn't exist
|
||||||
|
await setupTestUser(userData.email, userData.password, userData.role);
|
||||||
|
|
||||||
|
// Navigate to login page
|
||||||
|
await page.goto(`${baseURL}/auth`);
|
||||||
|
|
||||||
|
// Wait for page to load
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Fill login form
|
||||||
|
await page.fill('input[type="email"]', userData.email);
|
||||||
|
await page.fill('input[type="password"]', userData.password);
|
||||||
|
|
||||||
|
// Click login button
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
// Wait for navigation to complete
|
||||||
|
await page.waitForURL('**/', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Save authenticated state
|
||||||
|
const authFile = path.join(authDir, `${roleName}.json`);
|
||||||
|
await context.storageState({ path: authFile });
|
||||||
|
|
||||||
|
console.log(`✓ Created auth state for ${roleName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`✗ Failed to create auth state for ${roleName}:`, error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get auth credentials for a specific role
|
||||||
|
*/
|
||||||
|
export function getTestUserCredentials(role: keyof typeof TEST_USERS) {
|
||||||
|
return TEST_USERS[role];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login programmatically (for use within tests)
|
||||||
|
*/
|
||||||
|
export async function loginAsUser(
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
): Promise<{ userId: string; accessToken: string }> {
|
||||||
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
if (!data.user || !data.session) throw new Error('Login failed');
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: data.user.id,
|
||||||
|
accessToken: data.session.access_token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout programmatically
|
||||||
|
*/
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
}
|
||||||
193
tests/fixtures/database.ts
vendored
Normal file
193
tests/fixtures/database.ts
vendored
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Database Fixtures for Playwright Tests
|
||||||
|
*
|
||||||
|
* Provides direct database access for test setup and teardown using service role.
|
||||||
|
* IMPORTANT: Only use for test data management, never in production code!
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import type { Database } from '@/integrations/supabase/types';
|
||||||
|
|
||||||
|
const supabaseUrl = 'https://ydvtmnrszybqnbcqbdcy.supabase.co';
|
||||||
|
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4';
|
||||||
|
|
||||||
|
// For test setup/teardown only - requires service role key
|
||||||
|
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
// Regular client for authenticated operations
|
||||||
|
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey);
|
||||||
|
|
||||||
|
// Service role client for test setup/teardown (bypasses RLS)
|
||||||
|
export const supabaseAdmin = supabaseServiceRoleKey
|
||||||
|
? createClient<Database>(supabaseUrl, supabaseServiceRoleKey)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test user with specific role
|
||||||
|
*/
|
||||||
|
export async function setupTestUser(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
role: 'user' | 'moderator' | 'admin' | 'superuser' = 'user'
|
||||||
|
): Promise<{ userId: string; email: string }> {
|
||||||
|
if (!supabaseAdmin) {
|
||||||
|
throw new Error('Service role key not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user in auth
|
||||||
|
const { data: authData, error: authError } = await supabaseAdmin.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
email_confirm: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (authError) throw authError;
|
||||||
|
if (!authData.user) throw new Error('User creation failed');
|
||||||
|
|
||||||
|
const userId = authData.user.id;
|
||||||
|
|
||||||
|
// Create profile
|
||||||
|
const { error: profileError } = await supabaseAdmin
|
||||||
|
.from('profiles')
|
||||||
|
.upsert({
|
||||||
|
id: userId,
|
||||||
|
username: email.split('@')[0],
|
||||||
|
email,
|
||||||
|
role,
|
||||||
|
is_test_data: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (profileError) throw profileError;
|
||||||
|
|
||||||
|
return { userId, email };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all test data
|
||||||
|
*/
|
||||||
|
export async function cleanupTestData(): Promise<void> {
|
||||||
|
if (!supabaseAdmin) {
|
||||||
|
throw new Error('Service role key not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete in dependency order (child tables first)
|
||||||
|
const tables = [
|
||||||
|
'ride_photos',
|
||||||
|
'park_photos',
|
||||||
|
'submission_items',
|
||||||
|
'content_submissions',
|
||||||
|
'ride_versions',
|
||||||
|
'park_versions',
|
||||||
|
'company_versions',
|
||||||
|
'ride_model_versions',
|
||||||
|
'rides',
|
||||||
|
'ride_models',
|
||||||
|
'parks',
|
||||||
|
'companies',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
await supabaseAdmin
|
||||||
|
.from(table as any)
|
||||||
|
.delete()
|
||||||
|
.eq('is_test_data', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete test profiles
|
||||||
|
const { data: profiles } = await supabaseAdmin
|
||||||
|
.from('profiles')
|
||||||
|
.select('id')
|
||||||
|
.eq('is_test_data', true);
|
||||||
|
|
||||||
|
if (profiles) {
|
||||||
|
for (const profile of profiles) {
|
||||||
|
await supabaseAdmin.auth.admin.deleteUser(profile.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query database directly for assertions
|
||||||
|
*/
|
||||||
|
export async function queryDatabase<T = any>(
|
||||||
|
table: string,
|
||||||
|
query: (qb: any) => any
|
||||||
|
): Promise<T[]> {
|
||||||
|
if (!supabaseAdmin) {
|
||||||
|
throw new Error('Service role key not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await query(supabaseAdmin.from(table));
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a version to be created
|
||||||
|
*/
|
||||||
|
export async function waitForVersion(
|
||||||
|
entityId: string,
|
||||||
|
versionNumber: number,
|
||||||
|
table: string,
|
||||||
|
maxWaitMs: number = 5000
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!supabaseAdmin) {
|
||||||
|
throw new Error('Service role key not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWaitMs) {
|
||||||
|
const { data } = await supabaseAdmin
|
||||||
|
.from(table as any)
|
||||||
|
.select('version_number')
|
||||||
|
.eq('entity_id', entityId)
|
||||||
|
.eq('version_number', versionNumber)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (data) return true;
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve a submission directly (for test setup)
|
||||||
|
*/
|
||||||
|
export async function approveSubmissionDirect(submissionId: string): Promise<void> {
|
||||||
|
if (!supabaseAdmin) {
|
||||||
|
throw new Error('Service role key not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabaseAdmin.rpc('approve_submission', {
|
||||||
|
submission_id: submissionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get test data statistics
|
||||||
|
*/
|
||||||
|
export async function getTestDataStats(): Promise<Record<string, number>> {
|
||||||
|
if (!supabaseAdmin) {
|
||||||
|
throw new Error('Service role key not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tables = ['parks', 'rides', 'companies', 'ride_models', 'content_submissions'];
|
||||||
|
const stats: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
const { count } = await supabaseAdmin
|
||||||
|
.from(table as any)
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('is_test_data', true);
|
||||||
|
|
||||||
|
stats[table] = count || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
140
tests/fixtures/test-data.ts
vendored
Normal file
140
tests/fixtures/test-data.ts
vendored
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Test Data Generators
|
||||||
|
*
|
||||||
|
* Factory functions for creating realistic test data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
|
||||||
|
export interface ParkTestData {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
park_type: string;
|
||||||
|
status: string;
|
||||||
|
location_country: string;
|
||||||
|
location_city: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
opened_date: string;
|
||||||
|
is_test_data: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RideTestData {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
status: string;
|
||||||
|
park_id: string;
|
||||||
|
opened_date: string;
|
||||||
|
is_test_data: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyTestData {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
company_type: string;
|
||||||
|
person_type: string;
|
||||||
|
founded_date: string;
|
||||||
|
is_test_data: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RideModelTestData {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
manufacturer_id: string;
|
||||||
|
is_test_data: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate random park test data
|
||||||
|
*/
|
||||||
|
export function generateParkData(overrides?: Partial<ParkTestData>): ParkTestData {
|
||||||
|
const name = faker.company.name() + ' Park';
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
slug: faker.helpers.slugify(name).toLowerCase(),
|
||||||
|
description: faker.lorem.paragraphs(2),
|
||||||
|
park_type: faker.helpers.arrayElement(['theme_park', 'amusement_park', 'water_park']),
|
||||||
|
status: faker.helpers.arrayElement(['operating', 'closed', 'under_construction']),
|
||||||
|
location_country: faker.location.countryCode(),
|
||||||
|
location_city: faker.location.city(),
|
||||||
|
latitude: parseFloat(faker.location.latitude()),
|
||||||
|
longitude: parseFloat(faker.location.longitude()),
|
||||||
|
opened_date: faker.date.past({ years: 50 }).toISOString().split('T')[0],
|
||||||
|
is_test_data: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate random ride test data
|
||||||
|
*/
|
||||||
|
export function generateRideData(parkId: string, overrides?: Partial<RideTestData>): RideTestData {
|
||||||
|
const name = faker.word.adjective() + ' ' + faker.word.noun();
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
slug: faker.helpers.slugify(name).toLowerCase(),
|
||||||
|
description: faker.lorem.paragraphs(2),
|
||||||
|
category: faker.helpers.arrayElement(['roller_coaster', 'flat_ride', 'water_ride', 'dark_ride']),
|
||||||
|
status: faker.helpers.arrayElement(['operating', 'closed', 'sbno']),
|
||||||
|
park_id: parkId,
|
||||||
|
opened_date: faker.date.past({ years: 30 }).toISOString().split('T')[0],
|
||||||
|
is_test_data: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate random company test data
|
||||||
|
*/
|
||||||
|
export function generateCompanyData(
|
||||||
|
companyType: 'manufacturer' | 'designer' | 'operator' | 'property_owner',
|
||||||
|
overrides?: Partial<CompanyTestData>
|
||||||
|
): CompanyTestData {
|
||||||
|
const name = faker.company.name();
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
slug: faker.helpers.slugify(name).toLowerCase(),
|
||||||
|
description: faker.lorem.paragraphs(2),
|
||||||
|
company_type: companyType,
|
||||||
|
person_type: faker.helpers.arrayElement(['individual', 'company']),
|
||||||
|
founded_date: faker.date.past({ years: 100 }).toISOString().split('T')[0],
|
||||||
|
is_test_data: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate random ride model test data
|
||||||
|
*/
|
||||||
|
export function generateRideModelData(
|
||||||
|
manufacturerId: string,
|
||||||
|
overrides?: Partial<RideModelTestData>
|
||||||
|
): RideModelTestData {
|
||||||
|
const name = faker.word.adjective() + ' Model';
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
slug: faker.helpers.slugify(name).toLowerCase(),
|
||||||
|
description: faker.lorem.paragraphs(2),
|
||||||
|
category: faker.helpers.arrayElement(['roller_coaster', 'flat_ride', 'water_ride']),
|
||||||
|
manufacturer_id: manufacturerId,
|
||||||
|
is_test_data: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate unique test identifier
|
||||||
|
*/
|
||||||
|
export function generateTestId(): string {
|
||||||
|
return `test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
267
tests/helpers/loki-reporter.ts
Normal file
267
tests/helpers/loki-reporter.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* Custom Playwright Reporter for Grafana Loki
|
||||||
|
*
|
||||||
|
* Streams test events and results to Loki in real-time for centralized logging and monitoring.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
FullConfig,
|
||||||
|
FullResult,
|
||||||
|
Reporter,
|
||||||
|
Suite,
|
||||||
|
TestCase,
|
||||||
|
TestResult,
|
||||||
|
TestStep,
|
||||||
|
} from '@playwright/test/reporter';
|
||||||
|
|
||||||
|
interface LokiStream {
|
||||||
|
stream: Record<string, string>;
|
||||||
|
values: Array<[string, string]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LokiPushRequest {
|
||||||
|
streams: LokiStream[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LokiReporterOptions {
|
||||||
|
lokiUrl?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
batchSize?: number;
|
||||||
|
flushInterval?: number;
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Playwright reporter that sends logs to Grafana Loki
|
||||||
|
*/
|
||||||
|
export default class LokiReporter implements Reporter {
|
||||||
|
private lokiUrl: string;
|
||||||
|
private basicAuth?: string;
|
||||||
|
private batchSize: number;
|
||||||
|
private flushInterval: number;
|
||||||
|
private buffer: LokiStream[] = [];
|
||||||
|
private flushTimer?: NodeJS.Timeout;
|
||||||
|
private labels: Record<string, string>;
|
||||||
|
private testStartTime?: number;
|
||||||
|
|
||||||
|
constructor(options: LokiReporterOptions = {}) {
|
||||||
|
this.lokiUrl = options.lokiUrl || process.env.GRAFANA_LOKI_URL || 'http://localhost:3100';
|
||||||
|
this.batchSize = options.batchSize || 10;
|
||||||
|
this.flushInterval = options.flushInterval || 5000;
|
||||||
|
|
||||||
|
// Setup basic auth if credentials provided
|
||||||
|
const username = options.username || process.env.GRAFANA_LOKI_USERNAME;
|
||||||
|
const password = options.password || process.env.GRAFANA_LOKI_PASSWORD;
|
||||||
|
if (username && password) {
|
||||||
|
this.basicAuth = Buffer.from(`${username}:${password}`).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base labels for all logs
|
||||||
|
this.labels = {
|
||||||
|
job: 'playwright-tests',
|
||||||
|
workflow: process.env.GITHUB_WORKFLOW || 'local',
|
||||||
|
branch: process.env.GITHUB_REF_NAME || 'local',
|
||||||
|
commit: process.env.GITHUB_SHA || 'local',
|
||||||
|
run_id: process.env.GITHUB_RUN_ID || 'local',
|
||||||
|
...options.labels,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup periodic flush
|
||||||
|
this.flushTimer = setInterval(() => this.flush(), this.flushInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once before running tests
|
||||||
|
*/
|
||||||
|
async onBegin(config: FullConfig, suite: Suite) {
|
||||||
|
this.testStartTime = Date.now();
|
||||||
|
|
||||||
|
const testCount = suite.allTests().length;
|
||||||
|
await this.log({
|
||||||
|
event: 'test_suite_start',
|
||||||
|
message: `Starting Playwright test suite with ${testCount} tests`,
|
||||||
|
total_tests: testCount,
|
||||||
|
workers: config.workers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after a test has been started
|
||||||
|
*/
|
||||||
|
async onTestBegin(test: TestCase) {
|
||||||
|
await this.log({
|
||||||
|
event: 'test_start',
|
||||||
|
test_name: test.title,
|
||||||
|
test_file: this.getRelativePath(test.location.file),
|
||||||
|
project: test.parent.project()?.name || 'unknown',
|
||||||
|
message: `Test started: ${test.title}`,
|
||||||
|
}, {
|
||||||
|
browser: test.parent.project()?.name || 'unknown',
|
||||||
|
test_file: this.getRelativePath(test.location.file),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after a test has been finished
|
||||||
|
*/
|
||||||
|
async onTestEnd(test: TestCase, result: TestResult) {
|
||||||
|
const status = result.status;
|
||||||
|
const duration = result.duration;
|
||||||
|
const browser = test.parent.project()?.name || 'unknown';
|
||||||
|
const testFile = this.getRelativePath(test.location.file);
|
||||||
|
|
||||||
|
// Determine log message based on status
|
||||||
|
let message = `Test ${status}: ${test.title}`;
|
||||||
|
if (status === 'failed' || status === 'timedOut') {
|
||||||
|
message = `${message} - ${result.error?.message || 'Unknown error'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.log({
|
||||||
|
event: 'test_end',
|
||||||
|
test_name: test.title,
|
||||||
|
test_file: testFile,
|
||||||
|
status,
|
||||||
|
duration_ms: duration,
|
||||||
|
retry: result.retry,
|
||||||
|
message,
|
||||||
|
error: status === 'failed' ? result.error?.message : undefined,
|
||||||
|
error_stack: status === 'failed' ? result.error?.stack : undefined,
|
||||||
|
}, {
|
||||||
|
browser,
|
||||||
|
test_file: testFile,
|
||||||
|
test_name: test.title,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log individual test steps for failed tests
|
||||||
|
if (status === 'failed') {
|
||||||
|
for (const step of result.steps) {
|
||||||
|
await this.logStep(test, step, browser, testFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log test step details
|
||||||
|
*/
|
||||||
|
private async logStep(test: TestCase, step: TestStep, browser: string, testFile: string) {
|
||||||
|
await this.log({
|
||||||
|
event: 'test_step',
|
||||||
|
test_name: test.title,
|
||||||
|
step_title: step.title,
|
||||||
|
step_category: step.category,
|
||||||
|
duration_ms: step.duration,
|
||||||
|
error: step.error?.message,
|
||||||
|
message: `Step: ${step.title}`,
|
||||||
|
}, {
|
||||||
|
browser,
|
||||||
|
test_file: testFile,
|
||||||
|
step_category: step.category,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after all tests have been finished
|
||||||
|
*/
|
||||||
|
async onEnd(result: FullResult) {
|
||||||
|
const duration = this.testStartTime ? Date.now() - this.testStartTime : 0;
|
||||||
|
|
||||||
|
await this.log({
|
||||||
|
event: 'test_suite_end',
|
||||||
|
status: result.status,
|
||||||
|
duration_ms: duration,
|
||||||
|
message: `Test suite ${result.status} in ${(duration / 1000).toFixed(2)}s`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flush remaining logs
|
||||||
|
await this.flush();
|
||||||
|
|
||||||
|
// Clear flush timer
|
||||||
|
if (this.flushTimer) {
|
||||||
|
clearInterval(this.flushTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a message to Loki
|
||||||
|
*/
|
||||||
|
private async log(data: Record<string, any>, extraLabels: Record<string, string> = {}) {
|
||||||
|
const timestamp = Date.now() * 1000000; // Convert to nanoseconds
|
||||||
|
|
||||||
|
const stream: LokiStream = {
|
||||||
|
stream: {
|
||||||
|
...this.labels,
|
||||||
|
...extraLabels,
|
||||||
|
event: data.event || 'log',
|
||||||
|
},
|
||||||
|
values: [[timestamp.toString(), JSON.stringify(data)]],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.buffer.push(stream);
|
||||||
|
|
||||||
|
// Flush if buffer is full
|
||||||
|
if (this.buffer.length >= this.batchSize) {
|
||||||
|
await this.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush buffered logs to Loki
|
||||||
|
*/
|
||||||
|
private async flush() {
|
||||||
|
if (this.buffer.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: LokiPushRequest = {
|
||||||
|
streams: this.buffer,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.buffer = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.basicAuth) {
|
||||||
|
headers['Authorization'] = `Basic ${this.basicAuth}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.lokiUrl}/loki/api/v1/push`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Failed to send logs to Loki: ${response.status} ${response.statusText}`);
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error(`Response: ${errorText}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending logs to Loki:', error);
|
||||||
|
// Re-add to buffer to retry
|
||||||
|
this.buffer.push(...payload.streams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get relative path from project root
|
||||||
|
*/
|
||||||
|
private getRelativePath(filePath: string): string {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
if (filePath.startsWith(cwd)) {
|
||||||
|
return filePath.substring(cwd.length + 1);
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print summary to console
|
||||||
|
*/
|
||||||
|
printsToStdio() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
tests/helpers/page-objects/LoginPage.ts
Normal file
48
tests/helpers/page-objects/LoginPage.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Login Page Object Model
|
||||||
|
*
|
||||||
|
* Encapsulates interactions with the login page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
export class LoginPage {
|
||||||
|
constructor(private page: Page) {}
|
||||||
|
|
||||||
|
async goto() {
|
||||||
|
await this.page.goto('/auth');
|
||||||
|
await this.page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(email: string, password: string) {
|
||||||
|
await this.page.fill('input[type="email"]', email);
|
||||||
|
await this.page.fill('input[type="password"]', password);
|
||||||
|
await this.page.click('button[type="submit"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectLoginSuccess() {
|
||||||
|
// Wait for navigation away from auth page
|
||||||
|
await this.page.waitForURL('**/', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify we're on homepage or dashboard
|
||||||
|
await expect(this.page).not.toHaveURL(/\/auth/);
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectLoginError(message?: string) {
|
||||||
|
// Check for error toast or message
|
||||||
|
if (message) {
|
||||||
|
await expect(this.page.getByText(message)).toBeVisible();
|
||||||
|
} else {
|
||||||
|
// Just verify we're still on auth page
|
||||||
|
await expect(this.page).toHaveURL(/\/auth/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickSignUp() {
|
||||||
|
await this.page.click('text=Sign up');
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickForgotPassword() {
|
||||||
|
await this.page.click('text=Forgot password');
|
||||||
|
}
|
||||||
|
}
|
||||||
100
tests/helpers/page-objects/ModerationQueuePage.ts
Normal file
100
tests/helpers/page-objects/ModerationQueuePage.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Moderation Queue Page Object Model
|
||||||
|
*
|
||||||
|
* Encapsulates interactions with the moderation queue.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
export class ModerationQueuePage {
|
||||||
|
constructor(private page: Page) {}
|
||||||
|
|
||||||
|
async goto() {
|
||||||
|
await this.page.goto('/moderation/queue');
|
||||||
|
await this.page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimSubmission(index: number = 0) {
|
||||||
|
const claimButtons = this.page.locator('button:has-text("Claim")');
|
||||||
|
await claimButtons.nth(index).click();
|
||||||
|
|
||||||
|
// Wait for lock to be acquired
|
||||||
|
await expect(this.page.getByText(/claimed by you/i)).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveSubmission(reason?: string) {
|
||||||
|
// Click approve button
|
||||||
|
await this.page.click('button:has-text("Approve")');
|
||||||
|
|
||||||
|
// Fill optional reason if provided
|
||||||
|
if (reason) {
|
||||||
|
await this.page.fill('textarea[placeholder*="reason"]', reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm in dialog
|
||||||
|
await this.page.click('button:has-text("Confirm")');
|
||||||
|
|
||||||
|
// Wait for success toast
|
||||||
|
await expect(this.page.getByText(/approved/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectSubmission(reason: string) {
|
||||||
|
// Click reject button
|
||||||
|
await this.page.click('button:has-text("Reject")');
|
||||||
|
|
||||||
|
// Fill required reason
|
||||||
|
await this.page.fill('textarea[placeholder*="reason"]', reason);
|
||||||
|
|
||||||
|
// Confirm in dialog
|
||||||
|
await this.page.click('button:has-text("Confirm")');
|
||||||
|
|
||||||
|
// Wait for success toast
|
||||||
|
await expect(this.page.getByText(/rejected/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async extendLock() {
|
||||||
|
await this.page.click('button:has-text("Extend")');
|
||||||
|
await expect(this.page.getByText(/extended/i)).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseLock() {
|
||||||
|
await this.page.click('button:has-text("Release")');
|
||||||
|
await expect(this.page.getByText(/released/i)).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async filterByType(type: string) {
|
||||||
|
await this.page.selectOption('select[name="entity_type"]', type);
|
||||||
|
await this.page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
async filterByStatus(status: string) {
|
||||||
|
await this.page.selectOption('select[name="status"]', status);
|
||||||
|
await this.page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchBySubmitter(name: string) {
|
||||||
|
await this.page.fill('input[placeholder*="submitter"]', name);
|
||||||
|
await this.page.waitForTimeout(500); // Debounce
|
||||||
|
await this.page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectSubmissionVisible(submissionName: string) {
|
||||||
|
await expect(this.page.getByText(submissionName)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectSubmissionNotVisible(submissionName: string) {
|
||||||
|
await expect(this.page.getByText(submissionName)).not.toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectLockTimer() {
|
||||||
|
// Check that lock timer is visible (e.g., "14:59 remaining")
|
||||||
|
await expect(this.page.locator('[data-testid="lock-timer"]').or(
|
||||||
|
this.page.getByText(/\d{1,2}:\d{2}.*remaining/i)
|
||||||
|
)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectLockWarning() {
|
||||||
|
// Warning should appear at 2 minutes remaining
|
||||||
|
await expect(this.page.getByText(/lock.*expir/i)).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
87
tests/helpers/page-objects/ParkCreationPage.ts
Normal file
87
tests/helpers/page-objects/ParkCreationPage.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Park Creation Page Object Model
|
||||||
|
*
|
||||||
|
* Encapsulates interactions with the park creation/editing form.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
export class ParkCreationPage {
|
||||||
|
constructor(private page: Page) {}
|
||||||
|
|
||||||
|
async goto() {
|
||||||
|
await this.page.goto('/parks/new');
|
||||||
|
await this.page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
async fillBasicInfo(name: string, description: string) {
|
||||||
|
// Fill park name
|
||||||
|
await this.page.fill('input[name="name"]', name);
|
||||||
|
|
||||||
|
// Slug should auto-generate, but we can override if needed
|
||||||
|
// await this.page.fill('input[name="slug"]', slug);
|
||||||
|
|
||||||
|
// Fill description (might be a textarea or rich text editor)
|
||||||
|
const descField = this.page.locator('textarea[name="description"]').first();
|
||||||
|
await descField.fill(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectParkType(type: string) {
|
||||||
|
// Assuming a select or radio group
|
||||||
|
await this.page.click(`[data-park-type="${type}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectStatus(status: string) {
|
||||||
|
await this.page.click(`[data-status="${status}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchLocation(query: string) {
|
||||||
|
const searchInput = this.page.locator('input[placeholder*="location"]').or(
|
||||||
|
this.page.locator('input[placeholder*="search"]')
|
||||||
|
);
|
||||||
|
await searchInput.fill(query);
|
||||||
|
await this.page.waitForTimeout(500); // Wait for autocomplete
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectLocation(name: string) {
|
||||||
|
await this.page.click(`text=${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setOpeningDate(date: string, precision: 'day' | 'month' | 'year' = 'day') {
|
||||||
|
await this.page.fill('input[name="opened_date"]', date);
|
||||||
|
await this.page.selectOption('select[name="date_precision"]', precision);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadBannerImage(filePath: string) {
|
||||||
|
const fileInput = this.page.locator('input[type="file"][accept*="image"]').first();
|
||||||
|
await fileInput.setInputFiles(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadCardImage(filePath: string) {
|
||||||
|
const fileInputs = this.page.locator('input[type="file"][accept*="image"]');
|
||||||
|
await fileInputs.nth(1).setInputFiles(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadGalleryImages(filePaths: string[]) {
|
||||||
|
const galleryInput = this.page.locator('input[type="file"][multiple]');
|
||||||
|
await galleryInput.setInputFiles(filePaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectOperator(operatorName: string) {
|
||||||
|
await this.page.click('button:has-text("Select operator")');
|
||||||
|
await this.page.click(`text=${operatorName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitForm() {
|
||||||
|
await this.page.click('button[type="submit"]:has-text("Submit")');
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectSuccess() {
|
||||||
|
// Wait for success toast
|
||||||
|
await expect(this.page.getByText(/submitted.*review/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectValidationError(message: string) {
|
||||||
|
await expect(this.page.getByText(message)).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
30
tests/setup/global-setup.ts
Normal file
30
tests/setup/global-setup.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Playwright Global Setup
|
||||||
|
*
|
||||||
|
* Runs once before all tests to prepare the test environment.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FullConfig } from '@playwright/test';
|
||||||
|
import { setupAuthStates } from '../fixtures/auth';
|
||||||
|
import { cleanupTestData } from '../fixtures/database';
|
||||||
|
|
||||||
|
async function globalSetup(config: FullConfig) {
|
||||||
|
console.log('🚀 Starting global setup...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clean up any leftover test data from previous runs
|
||||||
|
console.log('🧹 Cleaning up leftover test data...');
|
||||||
|
await cleanupTestData();
|
||||||
|
|
||||||
|
// Setup authentication states for all user roles
|
||||||
|
console.log('🔐 Setting up authentication states...');
|
||||||
|
await setupAuthStates(config);
|
||||||
|
|
||||||
|
console.log('✅ Global setup complete');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Global setup failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default globalSetup;
|
||||||
39
tests/setup/global-teardown.ts
Normal file
39
tests/setup/global-teardown.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Playwright Global Teardown
|
||||||
|
*
|
||||||
|
* Runs once after all tests to clean up the test environment.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FullConfig } from '@playwright/test';
|
||||||
|
import { cleanupTestData, getTestDataStats } from '../fixtures/database';
|
||||||
|
|
||||||
|
async function globalTeardown(config: FullConfig) {
|
||||||
|
console.log('🧹 Starting global teardown...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get stats before cleanup
|
||||||
|
const statsBefore = await getTestDataStats();
|
||||||
|
console.log('📊 Test data before cleanup:', statsBefore);
|
||||||
|
|
||||||
|
// Clean up all test data
|
||||||
|
await cleanupTestData();
|
||||||
|
|
||||||
|
// Verify cleanup
|
||||||
|
const statsAfter = await getTestDataStats();
|
||||||
|
console.log('📊 Test data after cleanup:', statsAfter);
|
||||||
|
|
||||||
|
const totalRemaining = Object.values(statsAfter).reduce((sum, count) => sum + count, 0);
|
||||||
|
if (totalRemaining > 0) {
|
||||||
|
console.warn('⚠️ Some test data may not have been cleaned up properly');
|
||||||
|
} else {
|
||||||
|
console.log('✅ All test data cleaned up successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Global teardown complete');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Global teardown failed:', error);
|
||||||
|
// Don't throw - we don't want to fail the test run because of cleanup issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default globalTeardown;
|
||||||
Reference in New Issue
Block a user