diff --git a/.gitignore b/.gitignore index 1c05a798..47532cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,13 @@ browserstack.err local.log test/cross-browser-testing/CBT-tests-es5.js test/cross-browser-testing/CBT-tests.js -coverage/ \ No newline at end of file +coverage/ + +# Integration test artifacts +IntegrationTests/node_modules/ + +IntegrationTests/wiremock-recordings/mappings/*-?????.json +IntegrationTests/wiremock-recordings/__files/*-?????.txt +IntegrationTests/wiremock-recordings/__files/*-?????.json + +IntegrationTests/wiremock-recordings/stubs-backup/ \ No newline at end of file diff --git a/IntegrationTests/README.md b/IntegrationTests/README.md new file mode 100644 index 00000000..18378691 --- /dev/null +++ b/IntegrationTests/README.md @@ -0,0 +1,310 @@ +# Integration Tests + +Recording mParticle Web SDK API requests using WireMock for integration testing. + +## Prerequisites + +Before getting started, ensure you have the following installed: + +- **Node.js** (v18 or higher) +- **Docker** (for running WireMock locally) +- **Python 3** (for mapping transformation scripts) + +### Install Dependencies + +```bash +cd IntegrationTests +npm install +``` + +This will install: +- **Playwright** - Headless browser for running tests +- **http-server** - Local HTTP server for serving the test app + +### Install Playwright Browsers + +```bash +npx playwright install chromium +``` + +## Overview + +This project provides tools for integration testing the mParticle Web SDK by: +- Building the mParticle Web SDK from source +- Running a minimal test web application in a headless browser +- Recording/verifying all API traffic with WireMock + +The framework supports two modes: +1. **Recording Mode** - Captures SDK network requests to create baseline mappings +2. **Verification Mode** - Validates SDK requests against stored baselines + +## Directory Structure + +``` +IntegrationTests/ +├── common.sh # Shared shell functions +├── run_wiremock_recorder.sh # Recording mode script +├── run_clean_integration_tests.sh # Verification mode script +├── run-browser-test.js # Playwright browser test runner +├── sanitize_mapping.py # API key sanitization script +├── transform_mapping_body.py # Request body transformation script +├── package.json # Node.js dependencies +├── README.md # This file +├── test-app/ # Test web application +│ ├── index.html # HTML page +│ ├── test-app.js # Test logic +│ └── test-config.js # WireMock endpoint configuration +└── wiremock-recordings/ # Baseline mappings + ├── mappings/ # Request/response mapping files + └── __files/ # Response body files +``` + +## Available Scripts + +### `run_wiremock_recorder.sh` - Record API Requests + +Records all mParticle SDK API requests using WireMock for later use in integration testing. + +```bash +./run_wiremock_recorder.sh +``` + +**What it does:** +1. Builds mParticle Web SDK from source (`npm run build`) +2. Installs Playwright and dependencies +3. Starts WireMock container in recording mode +4. Starts local HTTP server for test app +5. Runs test app in headless Chromium browser +6. Records all API traffic to mapping files +7. Displays recorded files + +**Recorded Files:** +- `wiremock-recordings/mappings/*.json` - API request/response mappings +- `wiremock-recordings/__files/*` - Response body files + +### `run_clean_integration_tests.sh` - Verify Against Baselines + +Runs the test app and verifies all requests match the stored baseline mappings. + +```bash +./run_clean_integration_tests.sh +``` + +**What it does:** +1. Builds mParticle Web SDK from source +2. Escapes mapping bodies for WireMock compatibility +3. Starts WireMock in verification mode with stored mappings +4. Runs test app in headless browser +5. Verifies all requests matched mappings +6. Verifies all mappings were invoked +7. Returns exit code 1 on any verification failure + +**Exit Codes:** +- `0` - All tests passed +- `1` - Verification failed (unmatched requests or unused mappings) + +### `sanitize_mapping.py` - Remove API Keys + +Sanitizes WireMock mapping files by replacing API keys with regex patterns. + +```bash +python3 sanitize_mapping.py --test-name +``` + +**Example:** +```bash +python3 sanitize_mapping.py \ + wiremock-recordings/mappings/mapping-v1-us1-abc123-identify.json \ + --test-name identify +``` + +**What it does:** +- Replaces API keys in URLs with regex pattern `us1-[a-f0-9]+` +- Renames mapping file to `mapping-{test-name}.json` +- Renames body file to `body-{test-name}.json` +- Updates all references in the mapping JSON + +### `transform_mapping_body.py` - Transform Request Bodies + +Transforms request body JSON in WireMock mappings. + +```bash +# Display as readable JSON +python3 transform_mapping_body.py unescape + +# Convert back to escaped string (WireMock format) +python3 transform_mapping_body.py escape + +# Replace dynamic fields and save +python3 transform_mapping_body.py unescape+update +``` + +**Modes:** +- `unescape` - Convert escaped string to formatted JSON object +- `escape` - Convert JSON object back to escaped string +- `unescape+update` - Parse, replace dynamic fields with `${json-unit.ignore}`, and save + +**Dynamic fields replaced:** +- `session_id`, `timestamp_unixtime_ms`, `ct`, `est`, `sct` +- `id`, `mpid`, `das`, `sdk_version`, `client_generated_id` +- And more (see script for full list) + +## Development Workflow + +### Initial Recording of API Requests + +⚠️ **Important**: Recording requires a **real mParticle API key** and **Docker**. + +#### Step 1: Configure Real API Key (Temporarily) + +Edit `test-app/test-config.js` and replace the placeholder with your real API key: + +```javascript +// TEMPORARILY set your real API key for recording +API_KEY: 'us1-YOUR_REAL_API_KEY_HERE', // Replace with actual key +``` + +#### Step 2: Run the WireMock Recorder + +From your **local terminal** (not Cursor terminal, to access Docker): + +```bash +cd IntegrationTests +./run_wiremock_recorder.sh +``` + +This will: +- Build the SDK +- Start WireMock in recording mode +- Run all tests in a headless browser +- Save recordings to `wiremock-recordings/mappings/` + +#### Step 3: Process the Recordings + +Sanitize and transform each recorded mapping: + +```bash +# Sanitize API keys and rename +python3 sanitize_mapping.py \ + wiremock-recordings/mappings/mapping-xxx.json \ + --test-name identify + +# Transform request bodies +python3 transform_mapping_body.py \ + wiremock-recordings/mappings/mapping-identify.json \ + unescape+update +``` + +#### Step 4: Revert API Key to Placeholder + +**Critical**: After recording, restore the placeholder in `test-config.js`: + +```javascript +API_KEY: 'us1-test0000000000000000000000000', // Placeholder restored +``` + +#### Step 5: Verify and Commit + +```bash +# Test with placeholder key +./run_clean_integration_tests.sh + +# If tests pass, commit +git add wiremock-recordings/ test-app/test-config.js +git commit -m "Add sanitized baseline mappings" +``` + +### Running Integration Tests + +```bash +./run_clean_integration_tests.sh +``` + +The script will: +1. Rebuild the SDK with latest changes +2. Run tests against stored baselines +3. Report any mismatches +4. Exit with code 1 if verification fails + +## Test App Configuration + +The test app is configured via `test-app/test-config.js`: + +```javascript +const TEST_CONFIG = { + WIREMOCK_HOST: '127.0.0.1', + WIREMOCK_HTTPS_PORT: '8443', + API_KEY: 'us1-test0000000000000000000000000', + API_SECRET: 'test-secret-key', + // ... +}; +``` + +This configuration: +- Points all SDK endpoints to local WireMock (`127.0.0.1:8443`) +- Uses a test API key (will be sanitized in recordings) +- Works identically in local development and CI environments + +## Adding New Tests + +1. **Add test function** in `test-app/test-app.js`: + ```javascript + async function testMyNewFeature() { + log('Starting Test: My New Feature'); + // Call SDK methods + mParticle.logEvent('Test Event', mParticle.EventType.Other); + await waitForUpload(); + log('Test complete', 'success'); + } + ``` + +2. **Call the test** in `runTests()`: + ```javascript + async function runTests() { + // ... existing tests + await testMyNewFeature(); + } + ``` + +3. **Record new baselines:** + ```bash + ./run_wiremock_recorder.sh + ``` + +4. **Sanitize and commit** the new mappings. + +## Current Test Coverage + +| Test | Description | Status | +|------|-------------|--------| +| Identity | SDK initialization with identify call | Implemented | +| Simple Event | `logEvent()` with basic attributes | Implemented | +| Event with Attributes & Flags | `logEvent()` with custom flags | Implemented | +| Screen View | `logPageView()` | Implemented | +| Commerce Event | `eCommerce.logProductAction()` purchase | Implemented | + +### Test Details + +1. **Identity** (`testIdentify`) + - Initializes SDK with `identifyRequest` + - Verifies MPID is returned + - Sets user attribute + +2. **Simple Event** (`testSimpleEvent`) + - Logs `"Simple Event Name"` event + - Includes `{ SimpleKey: "SimpleValue" }` attributes + +3. **Event with Attributes & Flags** (`testEventWithCustomAttributesAndFlags`) + - Logs `"Event With Attributes"` event + - Includes string, number, boolean, date attributes + - Includes custom flags (not forwarded to providers) + +4. **Screen View** (`testLogScreen`) + - Logs `"Home Screen"` page view + - Includes page category and referrer + +5. **Commerce Event** (`testCommerceEvent`) + - Creates product ("Awesome Book", SKU: 1234567890) + - Logs purchase action with transaction attributes + - Includes revenue, tax, shipping, coupon code + diff --git a/IntegrationTests/common.sh b/IntegrationTests/common.sh new file mode 100755 index 00000000..fe21e42c --- /dev/null +++ b/IntegrationTests/common.sh @@ -0,0 +1,410 @@ +#!/bin/bash +# Common functions for WireMock integration testing scripts +# Adapted from mparticle-apple-sdk for Web SDK + +# ============================================================================ +# Configuration +# ============================================================================ + +# WireMock configuration (can be overridden by scripts) +HTTP_PORT=${HTTP_PORT:-8080} +HTTPS_PORT=${HTTPS_PORT:-8443} +MAPPINGS_DIR=${MAPPINGS_DIR:-"./wiremock-recordings"} + +# Test app configuration +TEST_APP_PORT=${TEST_APP_PORT:-3000} +TEST_APP_DIR="./test-app" + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SDK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Global variables +HTTP_SERVER_PID="" +BROWSER_PID="" + +# ============================================================================ +# SDK Build Functions +# ============================================================================ + +build_sdk() { + echo "[BUILD] Building mParticle Web SDK" + + cd "$SDK_DIR" + + # Install dependencies if needed + if [ ! -d "node_modules" ]; then + echo "[BUILD] Installing dependencies..." + npm ci || { echo "[ERROR] npm install failed"; exit 1; } + fi + + # Build the SDK + echo "[BUILD] Running build..." + npm run build || { echo "[ERROR] SDK build failed"; exit 1; } + + cd "$SCRIPT_DIR" + + echo "[BUILD] SDK built successfully at: $SDK_DIR/dist/mparticle.js" +} + +# ============================================================================ +# HTTP Server Functions +# ============================================================================ + +start_http_server() { + echo "[SERVER] Starting HTTP server for test app..." + + cd "$SDK_DIR" + + # Check if http-server is available + if ! command -v npx &> /dev/null; then + echo "[ERROR] npx not found. Please install Node.js" + exit 1 + fi + + # Start http-server in background, serving from SDK root so we can access both dist/ and IntegrationTests/ + npx http-server . -p $TEST_APP_PORT -c-1 --silent & + HTTP_SERVER_PID=$! + + cd "$SCRIPT_DIR" + + # Wait for server to start + echo "[SERVER] Waiting for HTTP server to start..." + local MAX_RETRIES=30 + local RETRY_COUNT=0 + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if curl -s -o /dev/null -w "%{http_code}" http://localhost:${TEST_APP_PORT}/ | grep -q "200\|301\|302"; then + echo "[SERVER] HTTP server is ready on port ${TEST_APP_PORT}" + break + fi + RETRY_COUNT=$((RETRY_COUNT + 1)) + sleep 0.5 + done + + if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then + echo "[ERROR] HTTP server failed to start" + exit 1 + fi +} + +stop_http_server() { + echo "[SERVER] Stopping HTTP server..." + + if [ -n "$HTTP_SERVER_PID" ]; then + # Kill the process (don't wait - it can hang) + kill $HTTP_SERVER_PID 2>/dev/null || true + # Give it a moment to die + sleep 0.5 + # Force kill if still alive + kill -9 $HTTP_SERVER_PID 2>/dev/null || true + HTTP_SERVER_PID="" + fi + + # Kill any orphaned http-server processes on our port + pkill -f "http-server.*-p.*${TEST_APP_PORT}" 2>/dev/null || true + + # Extra safety: kill anything holding port 3000 + local pids=$(lsof -ti:${TEST_APP_PORT} 2>/dev/null) + if [ -n "$pids" ]; then + echo "$pids" | xargs kill -9 2>/dev/null || true + fi +} + +# ============================================================================ +# Browser Functions (Playwright) +# ============================================================================ + +install_playwright() { + echo "[PLAYWRIGHT] Checking Playwright installation..." + + cd "$SCRIPT_DIR" + + # Check if node_modules exists in IntegrationTests + if [ ! -d "node_modules" ]; then + echo "[PLAYWRIGHT] Installing IntegrationTests dependencies..." + npm install || { echo "[ERROR] npm install failed"; exit 1; } + fi + + # Install Playwright browsers if needed + if ! npx playwright --version &> /dev/null; then + echo "[PLAYWRIGHT] Installing Playwright browsers..." + npx playwright install chromium || { echo "[ERROR] Playwright browser install failed"; exit 1; } + fi + + echo "[PLAYWRIGHT] Playwright is ready" +} + +run_test_in_browser() { + local mode=${1:-"verify"} # "record" or "verify" + + echo "[BROWSER] Running test app in headless browser (mode: $mode)..." + + cd "$SCRIPT_DIR" + + # Run the Playwright test script + node run-browser-test.js "$mode" || { + local exit_code=$? + echo "[ERROR] Browser test failed with exit code: $exit_code" + return $exit_code + } + + echo "[BROWSER] Browser test completed" +} + +# ============================================================================ +# WireMock Functions +# ============================================================================ + +wait_for_wiremock() { + echo "[WIREMOCK] Waiting for WireMock to start..." + local MAX_RETRIES=30 + local RETRY_COUNT=0 + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + # Try HTTPS first (our primary endpoint), fall back to HTTP admin + if curl -k -s -o /dev/null -w "%{http_code}" https://localhost:${HTTPS_PORT}/__admin/mappings 2>/dev/null | grep -q "200"; then + echo "[WIREMOCK] WireMock is ready (HTTPS)!" + break + elif curl -s -o /dev/null -w "%{http_code}" http://localhost:${HTTP_PORT}/__admin/mappings 2>/dev/null | grep -q "200"; then + echo "[WIREMOCK] WireMock is ready (HTTP)!" + break + fi + RETRY_COUNT=$((RETRY_COUNT + 1)) + echo " Waiting... ($RETRY_COUNT/$MAX_RETRIES)" + sleep 1 + done + + if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then + echo "[ERROR] WireMock failed to start within ${MAX_RETRIES} seconds" + exit 1 + fi + + echo "" +} + +start_wiremock() { + local mode=${1:-"verify"} # "record" or "verify" + + if [ "$mode" = "record" ]; then + echo "[WIREMOCK] Starting WireMock container in recording mode..." + else + echo "[WIREMOCK] Starting WireMock container in verification mode..." + fi + + stop_wiremock + + # Ensure mappings directory exists + mkdir -p "${MAPPINGS_DIR}/mappings" + mkdir -p "${MAPPINGS_DIR}/__files" + mkdir -p "${MAPPINGS_DIR}/proxies" + + # Handle proxy and stub files based on mode + if [ "$mode" = "record" ]; then + # Recording mode: + # 1. Move existing stubs aside (so they don't intercept requests) + # 2. Copy proxy files into mappings (so requests go to real servers) + echo "[WIREMOCK] Preparing for recording mode..." + + # Create backup directory for existing stubs + mkdir -p "${MAPPINGS_DIR}/stubs-backup" + + # Move existing mapping-*.json stubs to backup (except auto-generated ones with random suffixes) + for f in "${MAPPINGS_DIR}/mappings/"mapping-*.json; do + if [ -f "$f" ]; then + mv "$f" "${MAPPINGS_DIR}/stubs-backup/" 2>/dev/null || true + fi + done + echo "[WIREMOCK] Existing stubs moved to stubs-backup/" + + # Copy proxy files into mappings + echo "[WIREMOCK] Loading proxy configurations for recording..." + cp "${MAPPINGS_DIR}/proxies/"proxy-*.json "${MAPPINGS_DIR}/mappings/" 2>/dev/null || true + else + # Verification mode: + # 1. Restore stubs from backup if they exist + # 2. Remove proxy files from mappings + echo "[WIREMOCK] Preparing for verification mode..." + + # Restore stubs from backup if backup exists + if [ -d "${MAPPINGS_DIR}/stubs-backup" ] && [ "$(ls -A ${MAPPINGS_DIR}/stubs-backup 2>/dev/null)" ]; then + echo "[WIREMOCK] Restoring stubs from backup..." + mv "${MAPPINGS_DIR}/stubs-backup/"*.json "${MAPPINGS_DIR}/mappings/" 2>/dev/null || true + rmdir "${MAPPINGS_DIR}/stubs-backup" 2>/dev/null || true + fi + + # Remove proxy files from mappings + echo "[WIREMOCK] Removing proxy configurations for strict verification..." + rm -f "${MAPPINGS_DIR}/mappings/"proxy-*.json 2>/dev/null || true + fi + + # Base docker command + local docker_cmd="docker run -d --name ${CONTAINER_NAME} \ + -p ${HTTP_PORT}:8080 \ + -p ${HTTPS_PORT}:8443 \ + -v \"$(pwd)/${MAPPINGS_DIR}\":/home/wiremock \ + wiremock/wiremock:3.9.1 \ + --https-port 8443" + + # Add mode-specific parameters + if [ "$mode" = "record" ]; then + # Recording mode: use proxy mappings (proxy-*.json) to route to real servers + # and record the actual request/response pairs + docker_cmd="${docker_cmd} \ + --enable-browser-proxying \ + --preserve-host-header \ + --record-mappings \ + --verbose" + else + # Verification mode: use stored mappings to match requests + docker_cmd="${docker_cmd} \ + --verbose" + fi + + # Execute docker command + eval $docker_cmd || { + echo "[ERROR] Failed to start WireMock container" + exit 1 + } +} + +show_wiremock_logs() { + echo "" + echo "[WIREMOCK] Container logs:" + echo "================================================================" + docker logs ${CONTAINER_NAME} 2>&1 || echo "[ERROR] Could not retrieve container logs" + echo "================================================================" + echo "" +} + +stop_wiremock() { + echo "[WIREMOCK] Stopping WireMock containers..." + + # Stop the current script's container (with timeout) + if [ -n "${CONTAINER_NAME}" ]; then + docker stop -t 2 ${CONTAINER_NAME} 2>/dev/null || true + docker rm -f ${CONTAINER_NAME} 2>/dev/null || true + fi + + # Also stop ANY wiremock container that might be using our ports + # This handles the case where recorder was run but verify is starting + docker stop -t 2 wiremock-recorder 2>/dev/null || true + docker rm -f wiremock-recorder 2>/dev/null || true + docker stop -t 2 wiremock-verify 2>/dev/null || true + docker rm -f wiremock-verify 2>/dev/null || true + + # Kill any process holding our ports (extra safety, only if something is there) + local http_pids=$(lsof -ti:${HTTP_PORT} 2>/dev/null) + if [ -n "$http_pids" ]; then + echo "$http_pids" | xargs kill -9 2>/dev/null || true + fi + + local https_pids=$(lsof -ti:${HTTPS_PORT} 2>/dev/null) + if [ -n "$https_pids" ]; then + echo "$https_pids" | xargs kill -9 2>/dev/null || true + fi + + echo "[WIREMOCK] Cleanup complete" +} + +# ============================================================================ +# Verification Functions +# ============================================================================ + +verify_wiremock_results() { + echo "" + echo "[VERIFY] Verifying WireMock results..." + echo "" + + local WIREMOCK_PORT=${HTTP_PORT} + + # Count all requests + local TOTAL=$(curl -s http://localhost:${WIREMOCK_PORT}/__admin/requests | jq '.requests | length') + local UNMATCHED=$(curl -s http://localhost:${WIREMOCK_PORT}/__admin/requests/unmatched | jq '.requests | length') + local MATCHED=$((TOTAL - UNMATCHED)) + + echo "[VERIFY] WireMock summary:" + echo "--------------------------------" + echo " Total requests: $TOTAL" + echo " Matched requests: $MATCHED" + echo " Unmatched requests: $UNMATCHED" + echo "--------------------------------" + echo "" + + # Check for unmatched requests + if [ "$UNMATCHED" -gt 0 ]; then + echo "[ERROR] Found requests that did not match any mappings:" + curl -s http://localhost:${WIREMOCK_PORT}/__admin/requests/unmatched | \ + jq -r '.requests[] | " [\(.method)] \(.url)"' + echo "" + show_wiremock_logs + return 1 + else + echo "[SUCCESS] All incoming requests matched their mappings." + fi + + # Check for unused mappings + echo "" + echo "[VERIFY] Checking: were all mappings invoked..." + + # Get all non-proxy mapping URLs from files + local EXPECTED_MAPPINGS=$(jq -r 'select(.response.proxyBaseUrl == null) | "\(.request.method // "ANY") \(.request.url // .request.urlPattern // .request.urlPath // .request.urlPathPattern)"' ${MAPPINGS_DIR}/mappings/*.json 2>/dev/null | sort) + + # Get all actual request URLs + local ACTUAL_REQUESTS=$(curl -s http://localhost:${WIREMOCK_PORT}/__admin/requests | \ + jq -r '.requests[] | "\(.request.method) \(.request.url)"' | sort | uniq) + + # Check each expected mapping + local UNUSED_FOUND=false + while IFS= read -r mapping; do + if [ -n "$mapping" ]; then + local method=$(echo "$mapping" | awk '{print $1}') + local url=$(echo "$mapping" | awk '{$1=""; print $0}' | sed 's/^ //') + + # Check if mapping was used + local matched=false + + # For patterns, check by comparing base structure + if echo "$url" | grep -q '\[' || echo "$url" | grep -q '\\'; then + local url_start=$(echo "$url" | cut -d'[' -f1 | cut -d'\' -f1) + + if echo "$ACTUAL_REQUESTS" | grep -Fq "$method $url_start"; then + matched=true + fi + else + if echo "$ACTUAL_REQUESTS" | grep -Fq "$mapping"; then + matched=true + fi + fi + + if [ "$matched" = false ]; then + if [ "$UNUSED_FOUND" = false ]; then + echo "[WARNING] Some mappings were not invoked by the application:" + UNUSED_FOUND=true + fi + echo " $mapping" + fi + fi + done <<< "$EXPECTED_MAPPINGS" + + if [ "$UNUSED_FOUND" = false ]; then + echo "[SUCCESS] All recorded mappings were invoked by the application." + fi + + echo "" + echo "[SUCCESS] Verification completed successfully!" + return 0 +} + +# ============================================================================ +# Cleanup Functions +# ============================================================================ + +cleanup() { + echo "" + echo "[CLEANUP] Cleaning up..." + stop_http_server + stop_wiremock + echo "[CLEANUP] Done" +} + +trap cleanup EXIT INT TERM diff --git a/IntegrationTests/package-lock.json b/IntegrationTests/package-lock.json new file mode 100644 index 00000000..b8c15602 --- /dev/null +++ b/IntegrationTests/package-lock.json @@ -0,0 +1,644 @@ +{ + "name": "mparticle-web-sdk-integration-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mparticle-web-sdk-integration-tests", + "version": "1.0.0", + "devDependencies": { + "http-server": "^14.1.1", + "playwright": "^1.40.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "dev": true, + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/portfinder": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", + "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==", + "dev": true, + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.6" + }, + "engines": { + "node": ">= 10.12" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "dev": true, + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "dev": true + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dev": true, + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + } + } +} diff --git a/IntegrationTests/package.json b/IntegrationTests/package.json new file mode 100644 index 00000000..db8f15b1 --- /dev/null +++ b/IntegrationTests/package.json @@ -0,0 +1,19 @@ +{ + "name": "mparticle-web-sdk-integration-tests", + "version": "1.0.0", + "description": "Integration tests for mParticle Web SDK using WireMock", + "private": true, + "scripts": { + "test": "./run_clean_integration_tests.sh", + "record": "./run_wiremock_recorder.sh", + "install:playwright": "npx playwright install chromium" + }, + "devDependencies": { + "playwright": "^1.40.0", + "http-server": "^14.1.1" + }, + "engines": { + "node": ">=18.0.0" + } +} + diff --git a/IntegrationTests/run-browser-test.js b/IntegrationTests/run-browser-test.js new file mode 100644 index 00000000..acd2df6b --- /dev/null +++ b/IntegrationTests/run-browser-test.js @@ -0,0 +1,108 @@ +/** + * Playwright script to run the mParticle Web SDK integration test app + * in a headless browser and capture results. + */ + +const { chromium } = require('playwright'); + +// Configuration +const TEST_APP_URL = process.env.TEST_APP_URL || 'http://localhost:3000/IntegrationTests/test-app/'; +const TEST_TIMEOUT = parseInt(process.env.TEST_TIMEOUT || '30000', 10); +const MODE = process.argv[2] || 'verify'; // 'record' or 'verify' + +async function runTest() { + console.log('[BROWSER] Starting Playwright browser test...'); + console.log(` Mode: ${MODE}`); + console.log(` URL: ${TEST_APP_URL}`); + console.log(` Timeout: ${TEST_TIMEOUT}ms`); + console.log(''); + + const browser = await chromium.launch({ + headless: true, + args: [ + '--ignore-certificate-errors', // Accept self-signed WireMock cert + '--allow-insecure-localhost', + ], + }); + + const context = await browser.newContext({ + ignoreHTTPSErrors: true, // Accept self-signed certificates + }); + + const page = await context.newPage(); + + // Capture console logs from the page + page.on('console', (msg) => { + const text = msg.text(); + if (text.includes('[TEST]')) { + console.log(` [LOG] ${text}`); + } + }); + + // Capture page errors + page.on('pageerror', (error) => { + console.error(` [PAGE_ERROR] ${error.message}`); + }); + + // Capture failed requests (useful for debugging WireMock issues) + page.on('requestfailed', (request) => { + console.log(` [REQUEST_FAILED] ${request.method()} ${request.url()}`); + console.log(` Failure: ${request.failure()?.errorText || 'Unknown'}`); + }); + + // Log successful requests to WireMock (for debugging) + page.on('response', (response) => { + const url = response.url(); + if (url.includes('127.0.0.1') || url.includes('localhost:8443')) { + console.log(` [RESPONSE] ${response.request().method()} ${url} -> ${response.status()}`); + } + }); + + try { + console.log('[BROWSER] Navigating to test app...'); + await page.goto(TEST_APP_URL, { + waitUntil: 'networkidle', + timeout: TEST_TIMEOUT, + }); + + console.log('[BROWSER] Waiting for tests to complete...'); + + // Wait for TEST_COMPLETE flag to be set + await page.waitForFunction( + () => window.TEST_COMPLETE === true, + { timeout: TEST_TIMEOUT } + ); + + // Get test results + const testSuccess = await page.evaluate(() => window.TEST_SUCCESS); + const testError = await page.evaluate(() => window.TEST_ERROR); + + console.log(''); + + if (testSuccess) { + console.log('[SUCCESS] Browser test completed successfully!'); + await browser.close(); + return 0; + } else { + console.log(`[FAILED] Browser test failed: ${testError}`); + await browser.close(); + return 1; + } + + } catch (error) { + console.error(''); + console.error(`[ERROR] Browser test error: ${error.message}`); + await browser.close(); + return 1; + } +} + +// Run the test +runTest() + .then((exitCode) => { + process.exit(exitCode); + }) + .catch((error) => { + console.error('Fatal error:', error); + process.exit(1); + }); diff --git a/IntegrationTests/run_clean_integration_tests.sh b/IntegrationTests/run_clean_integration_tests.sh new file mode 100755 index 00000000..f96893d3 --- /dev/null +++ b/IntegrationTests/run_clean_integration_tests.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# Script for running mParticle Web SDK integration tests with WireMock verification +# Adapted from mparticle-apple-sdk for Web SDK + +set -e + +# ============================================================================ +# Configuration +# ============================================================================ + +HTTP_PORT=${1:-8080} +HTTPS_PORT=${2:-8443} +MAPPINGS_DIR=${3:-"./wiremock-recordings"} + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" + +# Container name for this script +CONTAINER_NAME="wiremock-verify" + +# ============================================================================ +# Mapping Body Transformation Functions +# ============================================================================ + +escape_mapping_bodies() { + echo "[TRANSFORM] Converting mapping bodies to escaped format (WireMock-compatible)..." + local MAPPINGS_FILES="${MAPPINGS_DIR}/mappings" + + if [ -d "$MAPPINGS_FILES" ] && [ "$(ls -A $MAPPINGS_FILES/*.json 2>/dev/null)" ]; then + for mapping_file in "$MAPPINGS_FILES"/*.json; do + if [ -f "$mapping_file" ]; then + python3 transform_mapping_body.py "$mapping_file" escape > /dev/null 2>&1 || { + echo "[WARNING] Failed to escape $(basename $mapping_file)" + } + fi + done + echo "[TRANSFORM] Mapping bodies escaped" + else + echo "[INFO] No mapping files found to escape" + fi +} + +unescape_mapping_bodies() { + echo "[TRANSFORM] Converting mapping bodies back to unescaped format (readable)..." + local MAPPINGS_FILES="${MAPPINGS_DIR}/mappings" + + if [ -d "$MAPPINGS_FILES" ] && [ "$(ls -A $MAPPINGS_FILES/*.json 2>/dev/null)" ]; then + for mapping_file in "$MAPPINGS_FILES"/*.json; do + if [ -f "$mapping_file" ]; then + python3 transform_mapping_body.py "$mapping_file" unescape > /dev/null 2>&1 || { + echo "[WARNING] Failed to unescape $(basename $mapping_file)" + } + fi + done + echo "[TRANSFORM] Mapping bodies unescaped" + else + echo "[INFO] No mapping files found to unescape" + fi +} + +# ============================================================================ +# Error Handling +# ============================================================================ + +# Custom cleanup that also unescapes mappings +cleanup_with_unescape() { + unescape_mapping_bodies + cleanup +} + +# Error handler that shows logs before cleanup +error_handler() { + local exit_code=$? + echo "" + echo "[ERROR] Script failed with exit code: $exit_code" + show_wiremock_logs + unescape_mapping_bodies + stop_http_server + stop_wiremock + exit $exit_code +} + +# Override trap from common.sh +trap cleanup_with_unescape EXIT INT TERM +trap error_handler ERR + +# ============================================================================ +# Main Execution +# ============================================================================ + +echo "" +echo "================================================================" +echo " mParticle Web SDK - Integration Test Verification" +echo "================================================================" +echo "" + +# Step 1: Build SDK +build_sdk + +# Step 2: Install Playwright +install_playwright + +# Step 3: Escape mapping bodies for WireMock +escape_mapping_bodies + +# Step 4: Start WireMock in verification mode +start_wiremock "verify" +wait_for_wiremock + +echo "[INFO] WireMock is running in verification mode" +echo "[INFO] Admin UI: http://localhost:${HTTP_PORT}/__admin" +echo "[INFO] HTTPS Endpoint: https://localhost:${HTTPS_PORT}" +echo "" + +# Step 5: Start HTTP server for test app +start_http_server + +echo "[INFO] Test app available at: http://localhost:${TEST_APP_PORT}/IntegrationTests/test-app/" +echo "" + +# Step 6: Run test in browser +echo "[TEST] Running integration tests..." +run_test_in_browser "verify" + +# Step 7: Verify WireMock results +verify_wiremock_results +VERIFY_RESULT=$? + +# Step 8: Unescape mapping bodies (restore readable format) +unescape_mapping_bodies + +# Step 9: Cleanup +stop_http_server +stop_wiremock + +# Exit with verification result +if [ $VERIFY_RESULT -ne 0 ]; then + echo "" + echo "[FAILED] Integration tests FAILED" + exit 1 +fi + +echo "" +echo "[PASSED] Integration tests PASSED" +exit 0 diff --git a/IntegrationTests/run_wiremock_recorder.sh b/IntegrationTests/run_wiremock_recorder.sh new file mode 100755 index 00000000..9ac41eed --- /dev/null +++ b/IntegrationTests/run_wiremock_recorder.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Script for recording mParticle Web SDK API requests using WireMock +# Adapted from mparticle-apple-sdk for Web SDK + +set -e + +# ============================================================================ +# Configuration +# ============================================================================ + +HTTP_PORT=${1:-8080} +HTTPS_PORT=${2:-8443} +MAPPINGS_DIR=${3:-"./wiremock-recordings"} +TARGET_URL=${4:-"https://identity.mparticle.com"} + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" + +# Container name for this script +CONTAINER_NAME="wiremock-recorder" + +# ============================================================================ +# Main Execution +# ============================================================================ + +echo "" +echo "================================================================" +echo " mParticle Web SDK - WireMock Recording Mode" +echo "================================================================" +echo "" + +# Step 1: Build SDK +build_sdk + +# Step 2: Install Playwright +install_playwright + +# Step 3: Prepare mappings directory +mkdir -p "${MAPPINGS_DIR}/mappings" +mkdir -p "${MAPPINGS_DIR}/__files" + +# Step 4: Start WireMock in recording mode +start_wiremock "record" +wait_for_wiremock + +echo "[INFO] WireMock is running and recording traffic to: ${MAPPINGS_DIR}" +echo "[INFO] Admin UI: http://localhost:${HTTP_PORT}/__admin" +echo "[INFO] HTTPS Proxy: https://localhost:${HTTPS_PORT}" +echo "" + +# Step 5: Start HTTP server for test app +start_http_server + +echo "[INFO] Test app available at: http://localhost:${TEST_APP_PORT}/IntegrationTests/test-app/" +echo "" + +# Step 6: Run test in browser +echo "[BROWSER] Running test app in browser..." +run_test_in_browser "record" + +# Step 7: Show recorded files +echo "" +echo "[INFO] Recorded files:" +echo "--------------------------------" +if [ -d "${MAPPINGS_DIR}/mappings" ]; then + ls -la "${MAPPINGS_DIR}/mappings/" 2>/dev/null || echo " No mapping files yet" +fi +echo "" + +# Step 8: Restore original stubs +echo "[RESTORE] Restoring original stub mappings..." +if [ -d "${MAPPINGS_DIR}/stubs-backup" ] && [ "$(ls -A ${MAPPINGS_DIR}/stubs-backup 2>/dev/null)" ]; then + mv "${MAPPINGS_DIR}/stubs-backup/"*.json "${MAPPINGS_DIR}/mappings/" 2>/dev/null || true + rmdir "${MAPPINGS_DIR}/stubs-backup" 2>/dev/null || true + echo "[RESTORE] Stubs restored" +fi + +# Step 9: Remove proxy files from mappings (cleanup) +rm -f "${MAPPINGS_DIR}/mappings/"proxy-*.json 2>/dev/null || true + +echo "" +echo "[SUCCESS] Recording complete!" +echo "" +echo "Next steps:" +echo " 1. Review NEW recorded mappings in ${MAPPINGS_DIR}/mappings/ (files with random suffixes)" +echo " 2. Sanitize API keys: python3 sanitize_mapping.py --test-name " +echo " 3. Transform request bodies: python3 transform_mapping_body.py unescape+update" +echo " 4. Revert test-config.js to use placeholder API key" +echo " 5. Run ./run_clean_integration_tests.sh to verify" +echo "" diff --git a/IntegrationTests/sanitize_mapping.py b/IntegrationTests/sanitize_mapping.py new file mode 100644 index 00000000..0c14fcb4 --- /dev/null +++ b/IntegrationTests/sanitize_mapping.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +""" +Script for sanitizing WireMock mapping files by: +- Replacing API keys in URLs with regex pattern: us1-[a-f0-9]+ +- Removing API keys from filenames +- Renaming files based on test name + +Adapted from mparticle-apple-sdk for Web SDK. + +Usage: + python3 sanitize_mapping.py --test-name + +Example: + python3 sanitize_mapping.py wiremock-recordings/mappings/mapping-v1-us1-abc123-identify.json --test-name identify + + Before: /v1/us1-abc123def456.../identify → After: /v1/us1-[a-f0-9]+/identify +""" + +import json +import sys +import argparse +import re +from pathlib import Path +from typing import Dict, Any, Tuple + + +def sanitize_url(url: str) -> str: + """ + Replaces API key in URL with a regex pattern for WireMock. + + Matches patterns like: + - /v1/us1-abc123.../identify -> /v1/us1-[a-f0-9]+/identify + - /v3/JS/us1-xyz789.../events -> /v3/JS/us1-[a-f0-9]+/events + + Args: + url: Original URL that may contain API key + + Returns: + Sanitized URL with API key replaced by regex pattern us1-[a-f0-9]+ + """ + # Pattern to match API keys like "us1-{32 hex characters}" + api_key_pattern = r'us1-[a-f0-9]{32}' + + # Replace API key with regex pattern for WireMock + sanitized = re.sub(api_key_pattern, 'us1-[a-f0-9]+', url) + + return sanitized + + +def sanitize_body_filename(filename: str, test_name: str = None) -> Tuple[str, str]: + """ + Removes API key from body filename and optionally renames based on test name. + + Args: + filename: Original filename that may contain API key + test_name: Optional test name to use for renaming + + Returns: + Tuple of (old_filename, new_filename) + """ + if not filename: + return (filename, filename) + + # If test_name is provided, use it for the new filename + if test_name: + # Get file extension + ext = Path(filename).suffix + new_filename = f"body-{test_name}{ext}" + return (filename, new_filename) + + # Otherwise, just remove API key from filename + # Pattern to match API keys in filename + api_key_pattern = r'us1-[a-f0-9]{32}' + + # Find API key in filename + match = re.search(api_key_pattern, filename) + if not match: + # No API key found, return as is + return (filename, filename) + + # Replace API key pattern with generic name + new_filename = re.sub(r'-us1-[a-f0-9]{32}', '', filename) + + return (filename, new_filename) + + +def rename_body_file(old_filename: str, new_filename: str, base_path: Path) -> bool: + """ + Renames body file if names are different. + + Args: + old_filename: Current filename + new_filename: New filename to rename to + base_path: Base path to the mapping file directory + + Returns: + True if file was renamed, False otherwise + """ + if old_filename == new_filename: + return False + + # Construct paths relative to the mapping file location + files_dir = base_path.parent.parent / "__files" + old_path = files_dir / old_filename + new_path = files_dir / new_filename + + if not old_path.exists(): + print(f"[WARNING] Body file not found: {old_path}") + return False + + if new_path.exists(): + print(f"[WARNING] Target body file already exists: {new_path}") + return False + + try: + old_path.rename(new_path) + print(f"[RENAME] Renamed body file: {old_filename} -> {new_filename}") + return True + except Exception as e: + print(f"[ERROR] Error renaming body file: {e}") + return False + + +def load_mapping_file(mapping_file: str) -> Tuple[Path, Dict[str, Any]]: + """ + Loads and parses mapping file. + + Args: + mapping_file: Path to mapping file + + Returns: + Tuple (Path to file, mapping data from JSON) + """ + # Check if mapping file exists + mapping_path = Path(mapping_file) + if not mapping_path.exists(): + print(f"[ERROR] Mapping file not found: {mapping_file}") + sys.exit(1) + + # Read mapping file + try: + with open(mapping_path, 'r', encoding='utf-8') as f: + mapping_data = json.load(f) + return (mapping_path, mapping_data) + except json.JSONDecodeError as e: + print(f"[ERROR] Failed to parse JSON from mapping file: {e}") + sys.exit(1) + except Exception as e: + print(f"[ERROR] Error reading mapping file: {e}") + sys.exit(1) + + +def rename_mapping_file(mapping_path: Path, test_name: str = None) -> Tuple[Path, bool]: + """ + Consistently renames mapping file if it contains API key or test name is provided. + + Args: + mapping_path: Path to mapping file + test_name: Optional test name to use for renaming + + Returns: + Tuple (Path to renamed file or original, whether file was renamed) + """ + filename = mapping_path.name + + # If test_name is provided, use it for the new filename + if test_name: + ext = mapping_path.suffix + new_filename = f"mapping-{test_name}{ext}" + new_path = mapping_path.parent / new_filename + + if new_path.exists() and new_path != mapping_path: + print(f"[WARNING] Target mapping file already exists: {new_path}") + return (mapping_path, False) + + if new_filename != filename: + try: + mapping_path.rename(new_path) + print(f"[RENAME] Renamed mapping file: {filename} -> {new_filename}") + return (new_path, True) + except Exception as e: + print(f"[ERROR] Error renaming mapping file: {e}") + return (mapping_path, False) + + return (mapping_path, False) + + # Otherwise, just remove API key from filename + api_key_pattern = r'-us1-[a-f0-9]{32}' + if re.search(api_key_pattern, filename): + new_filename = re.sub(api_key_pattern, '', filename) + new_path = mapping_path.parent / new_filename + + if new_path.exists(): + print(f"[WARNING] Target mapping file already exists: {new_path}") + return (mapping_path, False) + + try: + mapping_path.rename(new_path) + print(f"[RENAME] Renamed mapping file: {filename} -> {new_filename}") + return (new_path, True) + except Exception as e: + print(f"[ERROR] Error renaming mapping file: {e}") + return (mapping_path, False) + + return (mapping_path, False) + + +def sanitize_mapping_data(mapping_data: Dict[str, Any], mapping_path: Path, test_name: str = None) -> Tuple[Dict[str, Any], bool]: + """ + Sanitizes mapping data by: + 1. Removing API key from URL + 2. Renaming response body file and updating reference + + Args: + mapping_data: Mapping data from JSON + mapping_path: Path to mapping file (for locating body files) + test_name: Optional test name to use for renaming body file + + Returns: + Tuple (updated mapping data, whether any changes were made) + """ + changes_made = False + + # Sanitize URL + request_data = mapping_data.get('request', {}) + original_url = request_data.get('url') or request_data.get('urlPattern', '') + + if original_url: + sanitized_url = sanitize_url(original_url) + + if original_url != sanitized_url: + print(f"[SANITIZE] Sanitized API key from URL:") + print(f" Before: {original_url}") + print(f" After: {sanitized_url}") + + # After sanitization, URL contains regex pattern, so use urlPattern + # Remove 'url' if it exists and use 'urlPattern' instead + if 'url' in request_data: + del mapping_data['request']['url'] + print(f" Changed 'url' to 'urlPattern' (contains regex)") + mapping_data['request']['urlPattern'] = sanitized_url + + changes_made = True + + # Sanitize response body filename + response_data = mapping_data.get('response', {}) + original_body_filename = response_data.get('bodyFileName', '') + + if original_body_filename: + old_body_filename, new_body_filename = sanitize_body_filename(original_body_filename, test_name) + + if old_body_filename != new_body_filename: + # Rename the file + if rename_body_file(old_body_filename, new_body_filename, mapping_path): + # Update mapping data with new filename + mapping_data['response']['bodyFileName'] = new_body_filename + print(f"[SANITIZE] Updated body filename reference in mapping:") + print(f" Before: {old_body_filename}") + print(f" After: {new_body_filename}") + changes_made = True + + return (mapping_data, changes_made) + + +def save_mapping_file(mapping_data: Dict[str, Any], mapping_path: Path) -> None: + """ + Saves mapping data to file. + + Args: + mapping_data: Mapping data to save + mapping_path: Path where to save the file + """ + try: + with open(mapping_path, 'w', encoding='utf-8') as f: + json.dump(mapping_data, f, indent=2, ensure_ascii=False) + print(f"[SUCCESS] Saved updated mapping file: {mapping_path}") + except Exception as e: + print(f"[ERROR] Error saving mapping file: {e}") + sys.exit(1) + + +def main(): + # Set up command line argument parser + parser = argparse.ArgumentParser( + description='Sanitize WireMock mapping files by removing API keys and renaming based on test name', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Sanitize API keys and rename files + python3 sanitize_mapping.py wiremock-recordings/mappings/mapping-v1-us1-abc123-identify.json --test-name identify + python3 sanitize_mapping.py wiremock-recordings/mappings/mapping-v3-us1-abc123-events.json --test-name log-event + """ + ) + + parser.add_argument( + 'mapping_file', + help='Path to WireMock mapping file' + ) + + parser.add_argument( + '--test-name', + dest='test_name', + required=True, + help='Test name to use for renaming mapping and body files (e.g., "log-event", "identify")' + ) + + args = parser.parse_args() + + print(f"[INFO] Processing mapping file: {args.mapping_file}") + print(f"[INFO] Test name: {args.test_name}") + print() + + # Load mapping file + mapping_path, mapping_data = load_mapping_file(args.mapping_file) + + # Sanitize mapping data (URL and body filename) + mapping_data, data_changes_made = sanitize_mapping_data(mapping_data, mapping_path, args.test_name) + + # Rename mapping file itself + mapping_path, file_was_renamed = rename_mapping_file(mapping_path, args.test_name) + + # Save mapping file if any changes were made + if data_changes_made: + save_mapping_file(mapping_data, mapping_path) + + # Print summary + print() + if data_changes_made or file_was_renamed: + print("[SUCCESS] Sanitization complete!") + if file_was_renamed: + print(" - Mapping file renamed") + if data_changes_made: + print(" - Mapping data updated") + else: + print("[INFO] No changes needed - mapping is already sanitized") + + +if __name__ == "__main__": + main() + diff --git a/IntegrationTests/test-app/index.html b/IntegrationTests/test-app/index.html new file mode 100644 index 00000000..29f111f8 --- /dev/null +++ b/IntegrationTests/test-app/index.html @@ -0,0 +1,77 @@ + + + + + + mParticle Web SDK Integration Tests + + + +

🧪 mParticle Web SDK Integration Tests

+ +
+ Initializing... +
+ +

Test Log

+
+ + + + + + + + + + + + diff --git a/IntegrationTests/test-app/test-app.js b/IntegrationTests/test-app/test-app.js new file mode 100644 index 00000000..c7038c93 --- /dev/null +++ b/IntegrationTests/test-app/test-app.js @@ -0,0 +1,422 @@ +/** + * mParticle Web SDK Integration Test Application + * + * This test app initializes the mParticle SDK and sends test events + * to a local WireMock instance for integration testing. + * + * The app is designed to: + * 1. Initialize the SDK with WireMock endpoints + * 2. Perform identity calls + * 3. Signal completion for automated test runners + */ + +// ============================================================================ +// Logging Utilities +// ============================================================================ + +const logElement = document.getElementById('log'); +const statusElement = document.getElementById('status'); + +function log(message, type = 'info') { + const timestamp = new Date().toISOString().split('T')[1].slice(0, 12); + const prefix = { + info: 'ℹ️', + success: '✅', + error: '❌', + warn: '⚠️', + }[type] || 'ℹ️'; + + const line = document.createElement('div'); + line.className = `log-${type}`; + line.textContent = `[${timestamp}] ${prefix} ${message}`; + logElement.appendChild(line); + logElement.scrollTop = logElement.scrollHeight; + + // Also log to console for Playwright to capture + console.log(`[TEST] ${message}`); +} + +function setStatus(message, type = 'running') { + statusElement.textContent = message; + statusElement.className = `status ${type}`; +} + +// ============================================================================ +// Test Completion Signaling +// ============================================================================ + +// Global flag to signal test completion to Playwright +window.TEST_COMPLETE = false; +window.TEST_SUCCESS = false; +window.TEST_ERROR = null; + +function signalTestComplete(success, error = null) { + window.TEST_COMPLETE = true; + window.TEST_SUCCESS = success; + window.TEST_ERROR = error; + + if (success) { + setStatus('✅ All tests completed successfully!', 'success'); + log('All tests completed successfully', 'success'); + } else { + setStatus(`❌ Tests failed: ${error}`, 'error'); + log(`Tests failed: ${error}`, 'error'); + } +} + +// ============================================================================ +// SDK Configuration +// ============================================================================ + +function buildMParticleConfig() { + const baseUrl = TEST_CONFIG.WIREMOCK_BASE; + + return { + apiKey: TEST_CONFIG.API_KEY, + secretKey: TEST_CONFIG.API_SECRET, + + // Point all endpoints to WireMock + configUrl: `${baseUrl}/JS/v2/`, + identityUrl: `${baseUrl}/v1/`, + v1SecureServiceUrl: `${baseUrl}/v1/JS/`, + v2SecureServiceUrl: `${baseUrl}/v2/JS/`, + v3SecureServiceUrl: `${baseUrl}/v3/JS/`, + aliasUrl: `${baseUrl}/v1/identity/`, + + // SDK configuration for testing + isDevelopmentMode: true, + logLevel: 'verbose', + + // Disable features that may interfere with testing + useCookieStorage: false, + + // Identity request for initial identify call + identifyRequest: { + userIdentities: { + email: TEST_CONFIG.TEST_USER.email, + customerid: TEST_CONFIG.TEST_USER.customerId, + }, + }, + + // Callbacks + identityCallback: function(result) { + if (result.getUser()) { + log(`Identity callback: MPID = ${result.getUser().getMPID()}`, 'success'); + } else { + log('Identity callback: No user returned', 'warn'); + } + }, + }; +} + +// ============================================================================ +// Test Functions +// ============================================================================ + +/** + * Test 1: SDK Initialization with Identity + * This test initializes the SDK which triggers an identify call + */ +async function testIdentify() { + log('Starting Test: SDK Initialization with Identity'); + + return new Promise((resolve, reject) => { + const config = buildMParticleConfig(); + + // Add completion callback + config.identityCallback = function(result) { + if (result.getUser()) { + const mpid = result.getUser().getMPID(); + log(`Identify successful: MPID = ${mpid}`, 'success'); + + // Set a user attribute to verify the user object works + result.getUser().setUserAttribute('test_attribute', 'test_value'); + log('Set user attribute: test_attribute = test_value', 'info'); + + resolve({ mpid, user: result.getUser() }); + } else if (result.httpCode) { + log(`Identify failed with HTTP code: ${result.httpCode}`, 'error'); + reject(new Error(`Identity failed: HTTP ${result.httpCode}`)); + } else { + log('Identify returned no user', 'warn'); + resolve({ mpid: null, user: null }); + } + }; + + log(`Initializing SDK with API key: ${config.apiKey}`); + log(`Config URL: https://${config.configUrl}`); + log(`Identity URL: https://${config.identityUrl}`); + + try { + window.mParticle.init(config.apiKey, config); + log('mParticle.init() called', 'info'); + } catch (error) { + log(`mParticle.init() threw error: ${error.message}`, 'error'); + reject(error); + } + + // Timeout fallback + setTimeout(() => { + if (!window.mParticle.Identity.getCurrentUser()) { + log('Identity call timed out', 'warn'); + resolve({ mpid: null, user: null, timeout: true }); + } + }, TEST_CONFIG.INIT_TIMEOUT); + }); +} + +/** + * Test 2: Simple Custom Event + * Logs a basic custom event with simple attributes + * Equivalent to Apple SDK's testSimpleEvent() + */ +async function testSimpleEvent() { + log('Starting Test: Simple Custom Event'); + + try { + // Log a simple event with basic attributes + window.mParticle.logEvent( + 'Simple Event Name', + window.mParticle.EventType.Other, + { 'SimpleKey': 'SimpleValue' } + ); + + log('Logged simple event: "Simple Event Name"', 'success'); + + // Trigger upload to ensure event is sent + window.mParticle.upload(); + await new Promise(resolve => setTimeout(resolve, 500)); + + return { success: true }; + } catch (error) { + log(`Simple event failed: ${error.message}`, 'error'); + throw error; + } +} + +/** + * Test 3: Event with Custom Attributes and Flags + * Logs an event with various attribute types and custom flags + * Equivalent to Apple SDK's testEventWithCustomAttributesAndFlags() + */ +async function testEventWithCustomAttributesAndFlags() { + log('Starting Test: Event with Custom Attributes and Flags'); + + try { + // Create custom attributes with various types + const customAttributes = { + 'A_String_Key': 'A String Value', + 'A_Number_Key': 42, + 'A_Boolean_Key': true, + 'A_Date_Key': '2023-11-14T22:13:20Z', // Static date for deterministic testing + }; + + // Create custom flags (sent to mParticle but not forwarded to other providers) + const customFlags = { + 'Not_forwarded_to_providers': 'Top Secret' + }; + + // Log event with attributes and flags + window.mParticle.logEvent( + 'Event With Attributes', + window.mParticle.EventType.Transaction, + customAttributes, + customFlags + ); + + log('Logged event with custom attributes and flags', 'success'); + log(` Attributes: ${JSON.stringify(customAttributes)}`, 'info'); + log(` Flags: ${JSON.stringify(customFlags)}`, 'info'); + + // Trigger upload + window.mParticle.upload(); + await new Promise(resolve => setTimeout(resolve, 500)); + + return { success: true }; + } catch (error) { + log(`Event with attributes failed: ${error.message}`, 'error'); + throw error; + } +} + +/** + * Test 4: Screen View / Page View + * Logs a screen view event + * Equivalent to Apple SDK's testLogScreen() + */ +async function testLogScreen() { + log('Starting Test: Screen View'); + + try { + // Log a page view (screen view in web context) + window.mParticle.logPageView( + 'Home Screen', + { 'page_category': 'main', 'referrer': 'direct' } + ); + + log('Logged screen view: "Home Screen"', 'success'); + + // Trigger upload + window.mParticle.upload(); + await new Promise(resolve => setTimeout(resolve, 500)); + + return { success: true }; + } catch (error) { + log(`Screen view failed: ${error.message}`, 'error'); + throw error; + } +} + +/** + * Test 5: Commerce Event - Purchase + * Logs a commerce purchase event with product and transaction details + * Equivalent to Apple SDK's testCommerceEvent() + */ +async function testCommerceEvent() { + log('Starting Test: Commerce Event (Purchase)'); + + try { + // Create a product + const product = window.mParticle.eCommerce.createProduct( + 'Awesome Book', // Name + '1234567890', // SKU + 9.99, // Price + 1, // Quantity + 'Fiction', // Variant (optional) + 'Fiction', // Category (optional) + 'A Publisher', // Brand (optional) + 1, // Position (optional) + 'XYZ123' // Coupon Code (optional) + ); + + // Add custom attributes to product + product['custom_key'] = 'custom_value'; + + log(`Created product: ${product.Name} (SKU: ${product.Sku})`, 'info'); + + // Create transaction attributes + const transactionAttributes = { + Id: 'zyx098', + Affiliation: 'Book seller', + Revenue: 12.09, + Tax: 0.87, + Shipping: 1.23, + CouponCode: 'BOOK10' + }; + + // Create custom attributes for the commerce event + const customAttributes = { + 'an_extra_key': 'an_extra_value' + }; + + // Log the purchase event + window.mParticle.eCommerce.logProductAction( + window.mParticle.ProductActionType.Purchase, + [product], + customAttributes, + null, // custom flags + transactionAttributes + ); + + log('Logged commerce purchase event', 'success'); + log(` Transaction ID: ${transactionAttributes.Id}`, 'info'); + log(` Revenue: $${transactionAttributes.Revenue}`, 'info'); + + // Trigger upload + window.mParticle.upload(); + await new Promise(resolve => setTimeout(resolve, 500)); + + return { success: true }; + } catch (error) { + log(`Commerce event failed: ${error.message}`, 'error'); + throw error; + } +} + +// ============================================================================ +// Main Test Runner +// ============================================================================ + +async function runTests() { + setStatus('🚀 Running integration tests...', 'running'); + log('='.repeat(50)); + log('mParticle Web SDK Integration Tests'); + log('='.repeat(50)); + log(`WireMock endpoint: https://${TEST_CONFIG.WIREMOCK_BASE}`); + log(`API Key: ${TEST_CONFIG.API_KEY}`); + log(''); + + try { + // Test 1: Identity + log('--- Test 1: Identity ---'); + const identifyResult = await testIdentify(); + + if (identifyResult.timeout) { + throw new Error('Identity call timed out'); + } + + // Small delay between tests + await new Promise(resolve => setTimeout(resolve, 500)); + + // Test 2: Simple Custom Event + log(''); + log('--- Test 2: Simple Custom Event ---'); + await testSimpleEvent(); + + // Small delay between tests + await new Promise(resolve => setTimeout(resolve, 500)); + + // Test 3: Event with Custom Attributes and Flags + log(''); + log('--- Test 3: Event with Attributes and Flags ---'); + await testEventWithCustomAttributesAndFlags(); + + // Small delay between tests + await new Promise(resolve => setTimeout(resolve, 500)); + + // Test 4: Screen View + log(''); + log('--- Test 4: Screen View ---'); + await testLogScreen(); + + // Small delay between tests + await new Promise(resolve => setTimeout(resolve, 500)); + + // Test 5: Commerce Event + log(''); + log('--- Test 5: Commerce Event (Purchase) ---'); + await testCommerceEvent(); + + // Add a delay to ensure all network requests complete + log(''); + log('Waiting for pending requests to complete...'); + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Force upload any pending events + if (window.mParticle.upload) { + log('Triggering final upload...'); + window.mParticle.upload(); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + log(''); + log('='.repeat(50)); + signalTestComplete(true); + + } catch (error) { + signalTestComplete(false, error.message); + } +} + +// ============================================================================ +// Entry Point +// ============================================================================ + +// Start tests when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', runTests); +} else { + // Small delay to ensure all scripts are loaded + setTimeout(runTests, 100); +} + diff --git a/IntegrationTests/test-app/test-config.js b/IntegrationTests/test-app/test-config.js new file mode 100644 index 00000000..3021b006 --- /dev/null +++ b/IntegrationTests/test-app/test-config.js @@ -0,0 +1,46 @@ +/** + * Test Configuration for mParticle Web SDK Integration Tests + * + * This file contains configuration for pointing the SDK to WireMock + * for integration testing. Modify these values if you need to change + * the WireMock endpoint or API credentials. + * + * IMPORTANT FOR RECORDING MODE: + * When running ./run_wiremock_recorder.sh, you MUST use a REAL mParticle + * API key to record against production servers. The fake test key will + * result in 401 errors. + * + * For VERIFICATION MODE (./run_clean_integration_tests.sh), the test key + * works fine since responses come from stored WireMock mappings. + */ + +const TEST_CONFIG = { + // WireMock server configuration + WIREMOCK_HOST: '127.0.0.1', + WIREMOCK_HTTP_PORT: '8080', + WIREMOCK_HTTPS_PORT: '8443', + + // For RECORDING: Replace with your REAL mParticle API key + // For VERIFICATION: Use placeholder (stub mappings will match) + API_KEY: 'us1-test0000000000000000000000000', + API_SECRET: 'test-secret-key', + + // Test user identities + TEST_USER: { + email: 'test@example.com', + customerId: 'test-customer-123', + }, + + // Timeouts (in milliseconds) + INIT_TIMEOUT: 5000, + EVENT_TIMEOUT: 3000, +}; + +// Build WireMock base URL (without protocol - SDK adds https://) +TEST_CONFIG.WIREMOCK_BASE = `${TEST_CONFIG.WIREMOCK_HOST}:${TEST_CONFIG.WIREMOCK_HTTPS_PORT}`; + +// Export for use in test-app.js +if (typeof module !== 'undefined' && module.exports) { + module.exports = TEST_CONFIG; +} + diff --git a/IntegrationTests/transform_mapping_body.py b/IntegrationTests/transform_mapping_body.py new file mode 100644 index 00000000..3ada3618 --- /dev/null +++ b/IntegrationTests/transform_mapping_body.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +""" +Script for transforming request body in WireMock mappings. + +This script can: +- Unescape JSON body from string to formatted JSON object +- Escape JSON body from object back to string format +- Update dynamic fields with ${json-unit.ignore} placeholder + +Adapted from mparticle-apple-sdk for Web SDK. + +Usage: + python3 transform_mapping_body.py + +Modes: + unescape - Convert equalToJson from escaped string to formatted JSON object in file + escape - Convert equalToJson from JSON object back to escaped string in file + unescape+update - Parse, replace dynamic fields with ${json-unit.ignore}, convert to JSON object, and save + +Examples: + python3 transform_mapping_body.py wiremock-recordings/mappings/mapping-v1-identify.json unescape + python3 transform_mapping_body.py wiremock-recordings/mappings/mapping-v1-identify.json escape + python3 transform_mapping_body.py wiremock-recordings/mappings/mapping-v1-identify.json unescape+update +""" + +import json +import sys +import argparse +from pathlib import Path +from typing import Dict, Any, List, Union + + +# Default list of fields to replace with ${json-unit.ignore} +# These are Web SDK specific dynamic fields that change between runs +DEFAULT_REPLACE_FIELDS = [ + # Session and timing fields + 'session_id', # Session ID + 'session_start_unixtime_ms', # Session start timestamp + 'timestamp_unixtime_ms', # Event timestamp + 'ct', # Creation time + 'sct', # Session creation time + 'est', # Event start time + + # Device and environment fields + 'das', # Device application stamp + 'device_application_stamp', # Device application stamp (long form) + 'device_utc_offset', # Device timezone offset + + # Identity fields + 'id', # Various IDs (event, message) + 'mpid', # mParticle ID (may vary in test) + 'previous_mpid', # Previous MPID + 'request_id', # Request ID + 'request_timestamp_ms', # Request timestamp + + # SDK fields + 'sdk_version', # SDK version string + 'client_generated_id', # Client generated UUID + + # Batch fields + 'source_request_id', # Batch source request ID + + # Web SDK specific fields + 'cgid', # Client Generated ID + 'dbg', # Debug mode (can vary) + + # Legacy/iOS compatible fields (in case of shared mappings) + 'a', # App ID + 'bid', # Bundle ID / Build ID + 'bsv', # Build System Version + 'ck', # Cookies + 'dfs', # Device Fingerprint String + 'dlc', # Device Locale + 'dn', # Device Name + 'dosv', # Device OS Version + 'en', # Event Number + 'ict', # Init Config Time + 'lud', # Last Update Date + 'sid', # Session ID (short form) + 'vid', # Vendor ID +] + + +def replace_field_value(data: Union[Dict, List, Any], field_name: str, replacement_value: str) -> Union[Dict, List, Any]: + """ + Recursively replaces the value of a specified field in a JSON structure. + + Args: + data: JSON data (dict, list, or primitive value) + field_name: Name of the field to replace + replacement_value: New value to set for the field + + Returns: + Modified data structure with replaced field values + """ + if isinstance(data, dict): + result = {} + for key, value in data.items(): + if key == field_name: + result[key] = replacement_value + else: + result[key] = replace_field_value(value, field_name, replacement_value) + return result + elif isinstance(data, list): + return [replace_field_value(item, field_name, replacement_value) for item in data] + else: + return data + + +def replace_fields_from_list(data: Union[Dict, List, Any], field_names: List[str], replacement_value: str = "${json-unit.ignore}") -> Union[Dict, List, Any]: + """ + Replaces values of multiple fields in a JSON structure with a specified value. + + Args: + data: JSON data (dict, list, or primitive value) + field_names: List of field names to replace + replacement_value: Value to use for replacement (default: "${json-unit.ignore}") + + Returns: + Modified data structure with all specified fields replaced + """ + result = data + for field_name in field_names: + result = replace_field_value(result, field_name, replacement_value) + return result + + +def load_mapping_file(mapping_file: str) -> tuple[Path, Dict[str, Any]]: + """ + Loads and parses mapping file. + + Args: + mapping_file: Path to mapping file + + Returns: + Tuple (Path to file, mapping data from JSON) + """ + mapping_path = Path(mapping_file) + if not mapping_path.exists(): + print(f"[ERROR] Mapping file not found: {mapping_file}") + sys.exit(1) + + try: + with open(mapping_path, 'r', encoding='utf-8') as f: + mapping_data = json.load(f) + return (mapping_path, mapping_data) + except json.JSONDecodeError as e: + print(f"[ERROR] Failed to parse JSON from mapping file: {e}") + sys.exit(1) + except Exception as e: + print(f"[ERROR] Error reading mapping file: {e}") + sys.exit(1) + + +def get_request_body_from_mapping(mapping_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Extracts and unescapes request body from mapping data. + + Args: + mapping_data: Mapping data from JSON + + Returns: + Parsed request body as JSON object + """ + try: + request_data = mapping_data.get('request', {}) + body_patterns = request_data.get('bodyPatterns', []) + + if not body_patterns: + print("[ERROR] bodyPatterns not found in mapping") + sys.exit(1) + + equal_to_json = body_patterns[0].get('equalToJson') + if equal_to_json is None: + print("[ERROR] equalToJson not found in bodyPatterns") + sys.exit(1) + + # If equalToJson is already a dict/list (JSON object), return it directly + if isinstance(equal_to_json, (dict, list)): + return equal_to_json + + # Otherwise, parse escaped JSON string to actual JSON object + request_body = json.loads(equal_to_json) + return request_body + + except json.JSONDecodeError as e: + print(f"[ERROR] Failed to parse JSON from equalToJson: {e}") + sys.exit(1) + except Exception as e: + print(f"[ERROR] Error extracting body from mapping: {e}") + sys.exit(1) + + +def set_request_body_in_mapping(mapping_data: Dict[str, Any], request_body: Dict[str, Any], as_string: bool = True) -> Dict[str, Any]: + """ + Sets request body in mapping data. + + Args: + mapping_data: Mapping data from JSON + request_body: Request body as JSON object + as_string: If True, convert to escaped string. If False, keep as JSON object. + + Returns: + Updated mapping data + """ + try: + # Update mapping data + if 'request' not in mapping_data: + mapping_data['request'] = {} + if 'bodyPatterns' not in mapping_data['request']: + mapping_data['request']['bodyPatterns'] = [{}] + + if as_string: + # Convert JSON object to escaped string (for escape mode) + escaped_json = json.dumps(request_body, ensure_ascii=False) + mapping_data['request']['bodyPatterns'][0]['equalToJson'] = escaped_json + else: + # Keep as JSON object (for unescape mode) + mapping_data['request']['bodyPatterns'][0]['equalToJson'] = request_body + + mapping_data['request']['bodyPatterns'][0]['ignoreExtraElements'] = False + + return mapping_data + + except Exception as e: + print(f"[ERROR] Error setting body in mapping: {e}") + sys.exit(1) + + +def mode_unescape(mapping_data: Dict[str, Any], mapping_path: Path) -> None: + """ + Mode: unescape + Converts equalToJson from escaped string to formatted JSON object and saves to file. + + Args: + mapping_data: Mapping data from JSON + mapping_path: Path to mapping file + """ + request_body = get_request_body_from_mapping(mapping_data) + + print(f"[INFO] Mapping file: {mapping_path}") + print(f"[TRANSFORM] Converting equalToJson from string to formatted JSON object...") + + # Replace escaped string with actual JSON object + if 'request' in mapping_data and 'bodyPatterns' in mapping_data['request']: + mapping_data['request']['bodyPatterns'][0]['equalToJson'] = request_body + mapping_data['request']['bodyPatterns'][0]['ignoreExtraElements'] = False + + # Save updated mapping back to file + try: + # Save updated mapping + with open(mapping_path, 'w', encoding='utf-8') as f: + json.dump(mapping_data, f, indent=2, ensure_ascii=False) + + print(f"[SUCCESS] Mapping file updated successfully!") + print(f"[INFO] equalToJson is now a formatted JSON object:\n") + print(json.dumps(request_body, indent=2, ensure_ascii=False)) + + except Exception as e: + print(f"[ERROR] Error saving updated mapping file: {e}") + sys.exit(1) + + +def mode_escape(mapping_data: Dict[str, Any], mapping_path: Path) -> None: + """ + Mode: escape + Converts equalToJson from JSON object to escaped string and saves to file. + + Args: + mapping_data: Mapping data from JSON + mapping_path: Path to mapping file + """ + # Check if equalToJson is already a string (nothing to do) + try: + equal_to_json = mapping_data['request']['bodyPatterns'][0]['equalToJson'] + + # If it's already a string, nothing to escape + if isinstance(equal_to_json, str): + print(f"[INFO] Mapping file: {mapping_path}") + print(f"[INFO] equalToJson is already a string (escaped format)") + print(f"[SUCCESS] No action needed") + return + + # It's a JSON object, convert it to string + print(f"[INFO] Mapping file: {mapping_path}") + print(f"[TRANSFORM] Converting equalToJson from JSON object to escaped string...") + + escaped_json = json.dumps(equal_to_json, ensure_ascii=False) + mapping_data['request']['bodyPatterns'][0]['equalToJson'] = escaped_json + mapping_data['request']['bodyPatterns'][0]['ignoreExtraElements'] = False + + # Save updated mapping back to file + with open(mapping_path, 'w', encoding='utf-8') as f: + json.dump(mapping_data, f, indent=2, ensure_ascii=False) + + print(f"[SUCCESS] Mapping file updated successfully!") + print(f"[INFO] equalToJson is now an escaped string") + + except KeyError: + print(f"[ERROR] equalToJson not found in mapping") + sys.exit(1) + except Exception as e: + print(f"[ERROR] {e}") + sys.exit(1) + + +def mode_unescape_update(mapping_data: Dict[str, Any], mapping_path: Path) -> None: + """ + Mode: unescape+update + Unescapes body, replaces dynamic fields, converts to JSON object, and saves back to mapping file. + + Args: + mapping_data: Mapping data from JSON + mapping_path: Path to mapping file + """ + request_body = get_request_body_from_mapping(mapping_data) + + print(f"[INFO] Mapping file: {mapping_path}") + print(f"[TRANSFORM] Replacing {len(DEFAULT_REPLACE_FIELDS)} dynamic fields with ${{json-unit.ignore}}...") + + # Replace dynamic fields + updated_body = replace_fields_from_list(request_body, DEFAULT_REPLACE_FIELDS) + + # Update mapping data with modified body as JSON object (not string) + mapping_data = set_request_body_in_mapping(mapping_data, updated_body, as_string=False) + + # Save updated mapping back to file + try: + # Save updated mapping + with open(mapping_path, 'w', encoding='utf-8') as f: + json.dump(mapping_data, f, indent=2, ensure_ascii=False) + + print(f"[SUCCESS] Mapping file updated successfully!") + print(f"[INFO] Updated body with replaced fields:\n") + print(json.dumps(updated_body, indent=2, ensure_ascii=False)) + + except Exception as e: + print(f"[ERROR] Error saving updated mapping file: {e}") + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser( + description='Transform request body in WireMock mappings', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Modes: + unescape Convert equalToJson from escaped string to formatted JSON object + escape Convert equalToJson from JSON object back to escaped string + unescape+update Parse, replace dynamic fields, convert to JSON object, and save + +Examples: + python3 transform_mapping_body.py mappings/mapping-v1-identify.json unescape + python3 transform_mapping_body.py mappings/mapping-v1-identify.json escape + python3 transform_mapping_body.py mappings/mapping-v1-identify.json unescape+update + """ + ) + + parser.add_argument( + 'mapping_file', + help='Path to WireMock mapping file' + ) + + parser.add_argument( + 'mode', + choices=['unescape', 'escape', 'unescape+update'], + help='Operation mode' + ) + + args = parser.parse_args() + + # Load mapping file + mapping_path, mapping_data = load_mapping_file(args.mapping_file) + + # Execute based on mode + if args.mode == 'unescape': + mode_unescape(mapping_data, mapping_path) + elif args.mode == 'escape': + mode_escape(mapping_data, mapping_path) + elif args.mode == 'unescape+update': + mode_unescape_update(mapping_data, mapping_path) + + +if __name__ == "__main__": + main() + diff --git a/IntegrationTests/wiremock-recordings/__files/body-config.txt b/IntegrationTests/wiremock-recordings/__files/body-config.txt new file mode 100644 index 00000000..b4f3ce27 --- /dev/null +++ b/IntegrationTests/wiremock-recordings/__files/body-config.txt @@ -0,0 +1 @@ +{"appName":"Test Workspace","serviceUrl":"jssdk.mparticle.com/v2/JS/","secureServiceUrl":"jssdks.mparticle.com/v2/JS/","minWebviewBridgeVersion":2,"workspaceToken":"TEST1234","kitConfigs":[],"pixelConfigs":[],"flags":{"eventsV3":"100","eventBatchingIntervalMillis":"10000","offlineStorage":"100","directURLRouting":"False","audienceAPI":"False","cacheIdentity":"False"}} diff --git a/IntegrationTests/wiremock-recordings/__files/body-events.json b/IntegrationTests/wiremock-recordings/__files/body-events.json new file mode 100644 index 00000000..1d0a4ae2 --- /dev/null +++ b/IntegrationTests/wiremock-recordings/__files/body-events.json @@ -0,0 +1 @@ +{"mpid":"1234567890123456789","Store":{}} diff --git a/IntegrationTests/wiremock-recordings/__files/body-identify.json b/IntegrationTests/wiremock-recordings/__files/body-identify.json new file mode 100644 index 00000000..03504997 --- /dev/null +++ b/IntegrationTests/wiremock-recordings/__files/body-identify.json @@ -0,0 +1 @@ +{"context":null,"matched_identities":{"email":"test@example.com","customerid":"test-customer-123"},"is_ephemeral":false,"mpid":"1234567890123456789","is_logged_in":true} diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-config.json b/IntegrationTests/wiremock-recordings/mappings/mapping-config.json new file mode 100644 index 00000000..a5e8708b --- /dev/null +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-config.json @@ -0,0 +1,29 @@ +{ + "id": "9e823155-9e7b-3678-9840-76611d92b30e", + "priority": 0, + "request": { + "method": "GET", + "urlPattern": "/JS/v2/us1-[a-z0-9]+/config\\?env=1" + }, + "response": { + "status": 200, + "bodyFileName": "body-config.txt", + "headers": { + "X-Cache": "MISS, MISS", + "Server": "Kestrel", + "X-Origin-Name": "fastlyshield--shield_ssl_cache_iad_kiad7000158_IAD", + "Access-Control-Allow-Origin": "*", + "Date": "Mon, 05 Jan 2026 16:19:34 GMT", + "X-Timer": "S1767629975.982713,VS0,VE17", + "Via": "1.1 varnish, 1.1 varnish", + "Accept-Ranges": "bytes", + "Cache-Control": "public, max-age=3600", + "X-Served-By": "cache-iad-kiad7000158-IAD, cache-ewr-kewr1740086-EWR", + "Vary": "Accept-Encoding", + "X-Cache-Hits": "0, 0", + "Age": "0", + "Content-Type": "text/plain; charset=utf-8" + } + }, + "uuid": "9e823155-9e7b-3678-9840-76611d92b30e" +} \ No newline at end of file diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-cors-preflight.json b/IntegrationTests/wiremock-recordings/mappings/mapping-cors-preflight.json new file mode 100644 index 00000000..eb993ee7 --- /dev/null +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-cors-preflight.json @@ -0,0 +1,19 @@ +{ + "id": "cors-preflight-handler", + "name": "CORS Preflight Handler", + "priority": 5, + "request": { + "method": "OPTIONS", + "urlPattern": ".*" + }, + "response": { + "status": 204, + "headers": { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "content-type,x-mp-key", + "Access-Control-Max-Age": "86400" + } + } +} + diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-events.json b/IntegrationTests/wiremock-recordings/mappings/mapping-events.json new file mode 100644 index 00000000..8ad44e2c --- /dev/null +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-events.json @@ -0,0 +1,28 @@ +{ + "id": "77f1e1d6-9dbf-3a67-85aa-1263456fa315", + "priority": 0, + "request": { + "method": "POST", + "urlPattern": "/v3/JS/us1-[a-z0-9]+/events", + "bodyPatterns": [ + { + "matchesJsonPath": "$.mpid" + } + ] + }, + "response": { + "status": 202, + "bodyFileName": "body-events.json", + "headers": { + "X-Cache": "MISS", + "Server": "Kestrel", + "Access-Control-Allow-Origin": "*", + "Date": "Mon, 05 Jan 2026 16:19:35 GMT", + "Via": "1.1 varnish", + "Accept-Ranges": "bytes", + "Vary": "Accept-Encoding", + "Content-Type": "application/json" + } + }, + "uuid": "77f1e1d6-9dbf-3a67-85aa-1263456fa315" +} diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-identify.json b/IntegrationTests/wiremock-recordings/mappings/mapping-identify.json new file mode 100644 index 00000000..a411c84c --- /dev/null +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-identify.json @@ -0,0 +1,55 @@ +{ + "id": "6e3abb25-cf33-3f8a-a31d-7a3490888095", + "priority": 0, + "request": { + "url": "/v1/identify", + "method": "POST", + "bodyPatterns": [ + { + "equalToJson": { + "client_sdk": { + "platform": "web", + "sdk_vendor": "mparticle", + "sdk_version": "${json-unit.ignore}" + }, + "context": null, + "environment": "development", + "request_id": "${json-unit.ignore}", + "request_timestamp_ms": "${json-unit.ignore}", + "previous_mpid": "${json-unit.ignore}", + "known_identities": { + "email": "test@example.com", + "customerid": "test-customer-123", + "device_application_stamp": "${json-unit.ignore}" + } + }, + "ignoreArrayOrder": true, + "ignoreExtraElements": false + } + ] + }, + "response": { + "status": 200, + "bodyFileName": "body-identify.json", + "headers": { + "X-Cache": "MISS", + "X-MP-Trace-Id": "695be49767a5953822bf636b4c2b5298", + "Server": "Kestrel", + "X-Origin-Name": "4PrgpUXX9K0sNAH1JImfyI--F_us1_origin", + "Access-Control-Allow-Origin": "*", + "X-Fastly-Trace-Id": "1198109459", + "X-MP-Max-Age": "86400", + "Date": "Mon, 05 Jan 2026 16:19:35 GMT", + "X-Timer": "S1767629975.293589,VS0,VE42", + "Via": "1.1 varnish", + "Accept-Ranges": "bytes", + "Strict-Transport-Security": "max-age=900", + "Access-Control-Expose-Headers": "X-MP-Max-Age", + "X-Served-By": "cache-ewr-kewr1740025-EWR", + "Vary": "Accept-Encoding", + "X-Cache-Hits": "0", + "Content-Type": "application/json; charset=utf-8" + } + }, + "uuid": "6e3abb25-cf33-3f8a-a31d-7a3490888095" +} \ No newline at end of file diff --git a/IntegrationTests/wiremock-recordings/proxies/proxy-config.json b/IntegrationTests/wiremock-recordings/proxies/proxy-config.json new file mode 100644 index 00000000..f3e3249d --- /dev/null +++ b/IntegrationTests/wiremock-recordings/proxies/proxy-config.json @@ -0,0 +1,10 @@ +{ + "priority": 10, + "request": { + "urlPathPattern": "/JS/v2/.*" + }, + "response": { + "proxyBaseUrl": "https://jssdkcdns.mparticle.com" + } +} + diff --git a/IntegrationTests/wiremock-recordings/proxies/proxy-events.json b/IntegrationTests/wiremock-recordings/proxies/proxy-events.json new file mode 100644 index 00000000..270d793a --- /dev/null +++ b/IntegrationTests/wiremock-recordings/proxies/proxy-events.json @@ -0,0 +1,10 @@ +{ + "priority": 10, + "request": { + "urlPathPattern": "/v3/JS/.*" + }, + "response": { + "proxyBaseUrl": "https://jssdks.mparticle.com" + } +} + diff --git a/IntegrationTests/wiremock-recordings/proxies/proxy-identity.json b/IntegrationTests/wiremock-recordings/proxies/proxy-identity.json new file mode 100644 index 00000000..ef98a647 --- /dev/null +++ b/IntegrationTests/wiremock-recordings/proxies/proxy-identity.json @@ -0,0 +1,10 @@ +{ + "priority": 10, + "request": { + "urlPathPattern": "/v1/.*" + }, + "response": { + "proxyBaseUrl": "https://identity.mparticle.com" + } +} +