diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..9dca93b --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,58 @@ +name: E2E Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_dispatch: + inputs: + e2e_tests_ref: + description: 'Branch or ref of sdk-e2e-tests to use' + required: false + default: 'main' + +jobs: + e2e-tests: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + + steps: + - name: Checkout SDK + uses: actions/checkout@v4 + with: + path: sdk + + - name: Checkout sdk-e2e-tests + uses: actions/checkout@v4 + with: + repository: segmentio/sdk-e2e-tests + ref: ${{ inputs.e2e_tests_ref || 'main' }} + token: ${{ secrets.E2E_TESTS_TOKEN }} + path: sdk-e2e-tests + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: curl, json + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Run E2E tests + working-directory: sdk-e2e-tests + run: | + ./scripts/run-tests.sh \ + --sdk-dir "${{ github.workspace }}/sdk/e2e-cli" \ + --cli "php ${{ github.workspace }}/sdk/e2e-cli/main.php" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-test-results + path: sdk-e2e-tests/test-results/ + if-no-files-found: ignore diff --git a/.github/workflows/publish-e2e-cli.yml b/.github/workflows/publish-e2e-cli.yml new file mode 100644 index 0000000..ec647f0 --- /dev/null +++ b/.github/workflows/publish-e2e-cli.yml @@ -0,0 +1,39 @@ +name: Publish E2E CLI + +on: + push: + branches: [master] + paths: + - 'e2e-cli/**' + - 'lib/**' + schedule: + - cron: '0 0 1 * *' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout SDK + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: curl, json + + - name: Prepare artifact + run: | + mkdir -p artifact + cp e2e-cli/main.php artifact/ + cp e2e-cli/e2e-config.json artifact/ + cp e2e-cli/run-e2e.sh artifact/ + cp -r lib artifact/lib + + - name: Upload CLI artifact + uses: actions/upload-artifact@v4 + with: + name: e2e-cli-php + path: artifact/ + retention-days: 90 diff --git a/e2e-cli/README.md b/e2e-cli/README.md new file mode 100644 index 0000000..4126b08 --- /dev/null +++ b/e2e-cli/README.md @@ -0,0 +1,131 @@ +# analytics-php e2e-cli + +A small CLI tool that drives the analytics-php SDK in end-to-end tests. + +## Requirements + +- PHP 7.4 or 8.x + +No `composer install` is required — `main.php` uses a simple `spl_autoload_register` +to load the SDK classes directly from `../lib/`. + +## Usage + +```bash +php main.php --input '' +``` + +### Input JSON format + +```json +{ + "writeKey": "YOUR_WRITE_KEY", + "apiHost": "https://api.segment.io", + "sequences": [ + { + "delayMs": 0, + "events": [ + { + "type": "track", + "event": "Test Event", + "userId": "user-1", + "properties": { "plan": "pro" } + }, + { + "type": "identify", + "userId": "user-1", + "traits": { "email": "user@example.com" } + }, + { + "type": "page", + "userId": "user-1", + "name": "Home", + "category": "Nav" + }, + { + "type": "screen", + "userId": "user-1", + "name": "Main Screen" + }, + { + "type": "alias", + "userId": "new-id", + "previousId": "old-id" + }, + { + "type": "group", + "userId": "user-1", + "groupId": "group-1", + "traits": { "name": "Acme Corp" } + } + ] + } + ], + "config": { + "flushAt": 15, + "timeout": 10 + } +} +``` + +| Field | Description | +|---|---| +| `writeKey` | Segment write key | +| `apiHost` | Full URL of the Segment API host (scheme is stripped; PHP SDK always uses HTTPS) | +| `sequences[].delayMs` | Milliseconds to wait before processing this sequence | +| `sequences[].events` | List of events to send | +| `config.flushAt` | Number of events to accumulate before auto-flushing (maps to `flush_at`) | +| `config.timeout` | cURL timeout in seconds (maps to `curl_timeout`) | + +### Supported event types + +`track`, `identify`, `page`, `screen`, `alias`, `group` + +### Output JSON (stdout) + +On success: + +```json +{"success": true, "sentBatches": 1} +``` + +On failure: + +```json +{"success": false, "sentBatches": 0, "error": "HTTP 400: Bad Request"} +``` + +Exit code is `0` on success and `1` on failure. Debug logs are written to **stderr**. + +## Running E2E tests + +```bash +./run-e2e.sh +``` + +By default this expects the `sdk-e2e-tests` repo to be checked out alongside +the `analytics-php` repo: + +``` +parent/ + analytics-php/ + e2e-cli/ ← you are here + sdk-e2e-tests/ +``` + +Override the location with an environment variable: + +```bash +E2E_TESTS_DIR=/path/to/sdk-e2e-tests ./run-e2e.sh +``` + +Any additional arguments are forwarded to `sdk-e2e-tests/scripts/run-tests.sh`. + +## How it works + +1. `main.php` parses `--input` JSON from `$argv`. +2. A `Segment\Client` is created with `lib_curl` consumer and the provided options. +3. Each sequence is processed in order; `delayMs` introduces an optional pause. +4. After all events are enqueued, `flush()` is called to send them synchronously. +5. Errors captured by the `error_handler` callback or a `false` return from + `flush()` cause the script to exit with code `1`. diff --git a/e2e-cli/composer.json b/e2e-cli/composer.json new file mode 100644 index 0000000..cea3b6f --- /dev/null +++ b/e2e-cli/composer.json @@ -0,0 +1,13 @@ +{ + "name": "segment/analytics-php-e2e-cli", + "description": "E2E testing CLI for Segment Analytics PHP", + "type": "project", + "require": { + "php": "^7.4 || ^8.0" + }, + "autoload": { + "psr-4": { + "Segment\\": "../lib/" + } + } +} diff --git a/e2e-cli/e2e-config.json b/e2e-cli/e2e-config.json new file mode 100644 index 0000000..071d5fc --- /dev/null +++ b/e2e-cli/e2e-config.json @@ -0,0 +1,7 @@ +{ + "sdk": "php", + "test_suites": "basic", + "auto_settings": false, + "patch": null, + "env": {} +} diff --git a/e2e-cli/main.php b/e2e-cli/main.php new file mode 100644 index 0000000..4695981 --- /dev/null +++ b/e2e-cli/main.php @@ -0,0 +1,319 @@ +' + * + * Input JSON format: + * { + * "writeKey": "...", + * "apiHost": "https://...", + * "sequences": [ + * { + * "delayMs": 0, + * "events": [ + * {"type": "track", "event": "Test", "userId": "user-1", "properties": {...}}, + * ... + * ] + * } + * ], + * "config": { + * "flushAt": 15, + * "timeout": 10 + * } + * } + * + * Output JSON to stdout: + * {"success": true, "sentBatches": 1} + * {"success": false, "sentBatches": 0, "error": "..."} + * + * Exit code: 0 on success, 1 on failure. + */ + +// Autoload the Segment SDK from the parent lib/ directory. +spl_autoload_register(function (string $class): void { + $prefix = 'Segment\\'; + $baseDir = __DIR__ . '/../lib/'; + + if (strncmp($prefix, $class, strlen($prefix)) !== 0) { + return; + } + + $relative = substr($class, strlen($prefix)); + $file = $baseDir . str_replace('\\', '/', $relative) . '.php'; + + if (file_exists($file)) { + require $file; + } +}); + +/** + * LibCurl subclass that allows overriding the protocol (http:// vs https://). + * The base class hardcodes $protocol = 'https://', so we extend it to support + * plain-HTTP targets used by the mock test server. + */ +class E2eLibCurl extends \Segment\Consumer\LibCurl +{ + public function __construct(string $secret, array $options = []) + { + parent::__construct($secret, $options); + if (isset($options['protocol'])) { + $this->protocol = $options['protocol']; + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function debugLog(string $msg): void +{ + fwrite(STDERR, '[e2e-cli] ' . $msg . PHP_EOL); +} + +function outputResult(bool $success, int $sentBatches, string $error = ''): void +{ + $result = [ + 'success' => $success, + 'sentBatches' => $sentBatches, + ]; + if (!$success && $error !== '') { + $result['error'] = $error; + } + echo json_encode($result) . PHP_EOL; +} + +/** + * Parse the --input argument from $argv. + * + * @param array $argv + * @return string|null + */ +function parseInputArg(array $argv): ?string +{ + for ($i = 1, $iMax = count($argv); $i < $iMax; $i++) { + if ($argv[$i] === '--input' && isset($argv[$i + 1])) { + return $argv[$i + 1]; + } + if (strncmp($argv[$i], '--input=', 8) === 0) { + return substr($argv[$i], 8); + } + } + return null; +} + +/** + * Given a full URL like "https://api.segment.io" or "http://localhost:8080", + * return just the host[:port] portion that the PHP SDK expects. + * + * The PHP SDK prepends "https://" itself (hardcoded in QueueConsumer), so we + * strip the scheme here and only keep host + optional port. + * + * @param string $apiHost + * @return string + */ +function parseHost(string $apiHost): string +{ + // Remove trailing slash + $apiHost = rtrim($apiHost, '/'); + + // Strip scheme + $apiHost = preg_replace('#^https?://#', '', $apiHost); + + // Remove any path component — keep only host[:port] + $parts = explode('/', $apiHost, 2); + + return $parts[0]; +} + +/** + * Build the options array for Segment\Client. + * + * @param array $input + * @param array &$errors collected error messages + * @return array + */ +function buildClientOptions(array $input, array &$errors): array +{ + $config = $input['config'] ?? []; + $apiHost = $input['apiHost'] ?? ''; + + // Determine protocol from the apiHost scheme (default https://). + $scheme = 'https://'; + if (preg_match('#^(https?)://#i', $apiHost, $m)) { + $scheme = strtolower($m[1]) . '://'; + } + + $options = [ + // Use our subclass so we can inject a plain-http:// protocol for the + // mock test server (the base LibCurl hardcodes https://). + 'consumer' => E2eLibCurl::class, + 'protocol' => $scheme, + 'error_handler' => function (int $code, string $message) use (&$errors): void { + $msg = "HTTP {$code}: {$message}"; + debugLog('SDK error — ' . $msg); + $errors[] = $msg; + }, + ]; + + if ($apiHost !== '') { + $options['host'] = parseHost($apiHost); + debugLog('Using host: ' . $options['host'] . ' (protocol: ' . $scheme . ')'); + } + + if (isset($config['flushAt']) && is_numeric($config['flushAt'])) { + $options['flush_at'] = (int)$config['flushAt']; + debugLog('flush_at: ' . $options['flush_at']); + } + + if (isset($config['timeout']) && is_numeric($config['timeout'])) { + $options['curl_timeout'] = (int)$config['timeout']; + debugLog('curl_timeout: ' . $options['curl_timeout']); + } + + return $options; +} + +/** + * Map an event array from the input JSON to the array accepted by the SDK. + * Only passes fields that are set in the input event. + * + * @param array $event + * @return array + */ +function buildMessage(array $event): array +{ + $fieldMap = [ + 'userId', + 'anonymousId', + 'messageId', + 'timestamp', + 'traits', + 'properties', + 'name', + 'category', + 'groupId', + 'previousId', + 'context', + 'integrations', + 'event', + ]; + + $message = []; + foreach ($fieldMap as $field) { + if (array_key_exists($field, $event)) { + $message[$field] = $event[$field]; + } + } + + return $message; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +$inputJson = parseInputArg($argv); + +if ($inputJson === null) { + outputResult(false, 0, 'Missing required --input argument'); + exit(1); +} + +$input = json_decode($inputJson, true); + +if (!is_array($input)) { + outputResult(false, 0, 'Failed to parse --input JSON: ' . json_last_error_msg()); + exit(1); +} + +$writeKey = $input['writeKey'] ?? ''; +$sequences = $input['sequences'] ?? []; + +if ($writeKey === '') { + outputResult(false, 0, 'Missing writeKey in input'); + exit(1); +} + +$errors = []; + +// Build client options (error_handler captures into $errors by reference) +$options = buildClientOptions($input, $errors); + +debugLog('Creating Segment\\Client with writeKey=' . substr($writeKey, 0, 4) . '...'); + +$client = new \Segment\Client($writeKey, $options); + +$sentBatches = 0; + +foreach ($sequences as $seqIndex => $sequence) { + $delayMs = (int)($sequence['delayMs'] ?? 0); + + if ($delayMs > 0) { + debugLog("Sequence {$seqIndex}: sleeping {$delayMs}ms"); + usleep($delayMs * 1000); + } + + $events = $sequence['events'] ?? []; + debugLog("Sequence {$seqIndex}: processing " . count($events) . ' event(s)'); + + foreach ($events as $eventIndex => $event) { + $type = $event['type'] ?? ''; + $message = buildMessage($event); + + debugLog(" [{$seqIndex}/{$eventIndex}] Enqueueing {$type}"); + + switch ($type) { + case 'track': + $client->track($message); + break; + case 'identify': + $client->identify($message); + break; + case 'page': + $client->page($message); + break; + case 'screen': + $client->screen($message); + break; + case 'alias': + $client->alias($message); + break; + case 'group': + $client->group($message); + break; + default: + $errors[] = "Unknown event type: {$type}"; + debugLog(" Unknown event type: {$type}"); + break; + } + } +} + +debugLog('Flushing...'); +$flushOk = $client->flush(); + +if ($flushOk) { + $sentBatches = 1; + debugLog('Flush succeeded'); +} else { + debugLog('Flush returned false'); + $errors[] = 'Flush failed'; +} + +$hasErrors = !empty($errors); +$success = $flushOk && !$hasErrors; + +if ($success) { + outputResult(true, $sentBatches); + exit(0); +} else { + $errorMsg = implode('; ', $errors); + outputResult(false, $sentBatches, $errorMsg); + exit(1); +} diff --git a/e2e-cli/run-e2e.sh b/e2e-cli/run-e2e.sh new file mode 100755 index 0000000..43afd98 --- /dev/null +++ b/e2e-cli/run-e2e.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# +# Run E2E tests for analytics-php +# +# Prerequisites: Node.js 18+ and PHP 7.4+ +# +# Usage: +# ./run-e2e.sh [extra args passed to run-tests.sh] +# +# Override sdk-e2e-tests location: +# E2E_TESTS_DIR=../my-e2e-tests ./run-e2e.sh +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SDK_ROOT="$SCRIPT_DIR/.." +E2E_DIR="${E2E_TESTS_DIR:-$SDK_ROOT/../sdk-e2e-tests}" + +echo "=== Building analytics-php e2e-cli ===" + +# Run tests +cd "$E2E_DIR" +./scripts/run-tests.sh \ + --sdk-dir "$SCRIPT_DIR" \ + --cli "php $SCRIPT_DIR/main.php" \ + "$@"