From 49e249f498e952faec6160798b46c38f2ab0567b Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Thu, 7 May 2026 17:12:31 -0400 Subject: [PATCH 1/3] Add e2e-cli for E2E testing --- e2e-cli/Program.cs | 263 ++++++++++++++++++++++++++++++++++++++++ e2e-cli/README.md | 116 ++++++++++++++++++ e2e-cli/e2e-cli.csproj | 16 +++ e2e-cli/e2e-config.json | 7 ++ e2e-cli/run-e2e.sh | 34 ++++++ 5 files changed, 436 insertions(+) create mode 100644 e2e-cli/Program.cs create mode 100644 e2e-cli/README.md create mode 100644 e2e-cli/e2e-cli.csproj create mode 100644 e2e-cli/e2e-config.json create mode 100755 e2e-cli/run-e2e.sh diff --git a/e2e-cli/Program.cs b/e2e-cli/Program.cs new file mode 100644 index 0000000..a09cd45 --- /dev/null +++ b/e2e-cli/Program.cs @@ -0,0 +1,263 @@ +// e2e-cli: End-to-end test CLI for Analytics-CSharp +// Reads --input from args, sends events via the SDK, outputs JSON result to stdout. +// Debug/info logs go to stderr. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using Segment.Analytics; +using Segment.Analytics.Utilities; +using Segment.Serialization; +using JsonUtility = Segment.Serialization.JsonUtility; + +// ── Argument parsing ──────────────────────────────────────────────────────── +string? inputJson = null; +for (int i = 0; i < args.Length - 1; i++) +{ + if (args[i] == "--input") + { + inputJson = args[i + 1]; + break; + } +} + +if (inputJson == null) +{ + Console.Error.WriteLine("[e2e-cli] ERROR: --input argument is required"); + Console.WriteLine("{\"success\":false,\"error\":\"--input argument is required\"}"); + Environment.Exit(1); +} + +// ── Parse the input JSON ───────────────────────────────────────────────────── +JsonDocument doc; +try +{ + doc = JsonDocument.Parse(inputJson); +} +catch (Exception ex) +{ + Console.Error.WriteLine($"[e2e-cli] ERROR: Failed to parse --input JSON: {ex.Message}"); + Console.WriteLine($"{{\"success\":false,\"error\":\"Failed to parse input JSON: {Escape(ex.Message)}\"}}"); + Environment.Exit(1); + return; // unreachable, satisfies compiler +} + +JsonElement root = doc.RootElement; + +string writeKey = root.GetProperty("writeKey").GetString() + ?? throw new InvalidOperationException("writeKey is required"); + +string? apiHost = root.TryGetProperty("apiHost", out var apiHostEl) ? apiHostEl.GetString() : null; + +// config block (optional) +int flushAt = 15; +int flushInterval = 10; // seconds +if (root.TryGetProperty("config", out var configEl)) +{ + if (configEl.TryGetProperty("flushAt", out var fa)) flushAt = fa.GetInt32(); + if (configEl.TryGetProperty("flushInterval", out var fi)) + { + // input is in ms; SDK expects seconds + int fiMs = fi.GetInt32(); + flushInterval = Math.Max(1, fiMs / 1000); + } +} + +// ── Error handler ──────────────────────────────────────────────────────────── +var errors = new List(); +var errorHandler = new CapturingErrorHandler(errors); + +// ── Build configuration ────────────────────────────────────────────────────── +var configBuilder = new Configuration( + writeKey, + flushAt: flushAt, + flushInterval: flushInterval, + analyticsErrorHandler: errorHandler, + storageProvider: new InMemoryStorageProvider(), + apiHost: apiHost +); + +Console.Error.WriteLine($"[e2e-cli] Initialising analytics (writeKey={writeKey[..Math.Min(8, writeKey.Length)]}…, apiHost={apiHost ?? "default"})"); + +var analytics = new Analytics(configBuilder); + +// ── Process sequences ──────────────────────────────────────────────────────── +int totalEvents = 0; + +if (root.TryGetProperty("sequences", out var sequencesEl)) +{ + foreach (var sequence in sequencesEl.EnumerateArray()) + { + int delayMs = sequence.TryGetProperty("delayMs", out var delayEl) ? delayEl.GetInt32() : 0; + if (delayMs > 0) + { + Console.Error.WriteLine($"[e2e-cli] Waiting {delayMs}ms before next sequence"); + Thread.Sleep(delayMs); + } + + if (!sequence.TryGetProperty("events", out var eventsEl)) continue; + + foreach (var ev in eventsEl.EnumerateArray()) + { + string eventType = ev.GetProperty("type").GetString()?.ToLowerInvariant() ?? ""; + string? userId = ev.TryGetProperty("userId", out var uidEl) ? uidEl.GetString() : null; + + Console.Error.WriteLine($"[e2e-cli] Sending event type={eventType} userId={userId ?? "(none)"}"); + + switch (eventType) + { + case "identify": + { + JsonObject? traits = GetJsonObject(ev, "traits"); + if (userId != null) + analytics.Identify(userId, traits); + else + analytics.Identify(traits ?? new JsonObject()); + break; + } + + case "track": + { + // Set userId state first if provided + if (userId != null && analytics.UserId() != userId) + analytics.Identify(userId); + + string eventName = ev.TryGetProperty("event", out var enEl) + ? enEl.GetString() ?? "Unknown" + : "Unknown"; + JsonObject? properties = GetJsonObject(ev, "properties"); + analytics.Track(eventName, properties); + break; + } + + case "page": + { + if (userId != null && analytics.UserId() != userId) + analytics.Identify(userId); + + string title = ev.TryGetProperty("name", out var nameEl) + ? nameEl.GetString() ?? "" + : ""; + string category = ev.TryGetProperty("category", out var catEl) + ? catEl.GetString() ?? "" + : ""; + JsonObject? properties = GetJsonObject(ev, "properties"); + analytics.Page(title, properties, category); + break; + } + + case "screen": + { + if (userId != null && analytics.UserId() != userId) + analytics.Identify(userId); + + string title = ev.TryGetProperty("name", out var nameEl) + ? nameEl.GetString() ?? "" + : ""; + string category = ev.TryGetProperty("category", out var catEl) + ? catEl.GetString() ?? "" + : ""; + JsonObject? properties = GetJsonObject(ev, "properties"); + analytics.Screen(title, properties, category); + break; + } + + case "alias": + { + // For alias: previousId becomes the current userId state, newId is the alias target. + // The SDK Alias(newId) uses _userInfo._userId as previousId. + string? previousId = ev.TryGetProperty("previousId", out var prevEl) + ? prevEl.GetString() + : null; + string newId = userId ?? (ev.TryGetProperty("newId", out var newIdEl) + ? newIdEl.GetString() ?? "" + : ""); + + if (previousId != null && analytics.UserId() != previousId) + analytics.Identify(previousId); + + analytics.Alias(newId); + break; + } + + case "group": + { + if (userId != null && analytics.UserId() != userId) + analytics.Identify(userId); + + string groupId = ev.TryGetProperty("groupId", out var gidEl) + ? gidEl.GetString() ?? "" + : ""; + JsonObject? traits = GetJsonObject(ev, "traits"); + analytics.Group(groupId, traits); + break; + } + + default: + Console.Error.WriteLine($"[e2e-cli] WARNING: Unknown event type '{eventType}', skipping"); + continue; + } + + totalEvents++; + } + } +} + +Console.Error.WriteLine($"[e2e-cli] Flushing {totalEvents} event(s)…"); +analytics.Flush(); + +// Give the async pipeline time to upload +Thread.Sleep(5000); + +// ── Output result ───────────────────────────────────────────────────────────── +bool success = errors.Count == 0; +if (success) +{ + Console.WriteLine($"{{\"success\":true,\"sentBatches\":1}}"); + Environment.Exit(0); +} +else +{ + string combinedErrors = string.Join("; ", errors); + Console.WriteLine($"{{\"success\":false,\"sentBatches\":0,\"error\":\"{Escape(combinedErrors)}\"}}"); + Environment.Exit(1); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +static JsonObject? GetJsonObject(JsonElement parent, string key) +{ + if (!parent.TryGetProperty(key, out var el) || el.ValueKind == JsonValueKind.Null) + return null; + + // Serialise the JsonElement back to a JSON string, then parse with Segment's JsonUtility + string json = el.GetRawText(); + try + { + return JsonUtility.FromJson(json); + } + catch + { + return null; + } +} + +static string Escape(string s) => + s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", ""); + +// ── Error handler implementation ────────────────────────────────────────────── + +class CapturingErrorHandler : IAnalyticsErrorHandler +{ + private readonly List _errors; + + public CapturingErrorHandler(List errors) => _errors = errors; + + public void OnExceptionThrown(Exception e) + { + string msg = e.Message; + Console.Error.WriteLine($"[e2e-cli] SDK ERROR: {msg}"); + _errors.Add(msg); + } +} diff --git a/e2e-cli/README.md b/e2e-cli/README.md new file mode 100644 index 0000000..77f6fe6 --- /dev/null +++ b/e2e-cli/README.md @@ -0,0 +1,116 @@ +# Analytics-CSharp e2e-cli + +A small CLI tool used by the [sdk-e2e-tests](https://github.com/segmentio/sdk-e2e-tests) framework to run end-to-end tests against the Analytics-CSharp SDK. + +## Prerequisites + +- .NET 6 SDK or later +- Node.js 18+ (only needed when running the full test suite via `run-e2e.sh`) + +## Build + +```bash +cd e2e-cli +dotnet build -c Release -o build +``` + +## Usage + +```bash +dotnet build/e2e-cli.dll --input '' +``` + +The CLI reads a single `--input` argument containing a JSON string that describes the SDK configuration and event sequences to send. + +### Input JSON format + +```json +{ + "writeKey": "YOUR_WRITE_KEY", + "apiHost": "https://api.segment.io/v1", + "sequences": [ + { + "delayMs": 0, + "events": [ + {"type": "identify", "userId": "user-1", "traits": {"name": "Alice"}}, + {"type": "track", "userId": "user-1", "event": "Button Clicked", "properties": {"button": "signup"}}, + {"type": "page", "userId": "user-1", "name": "Home", "category": "Nav"}, + {"type": "screen", "userId": "user-1", "name": "Main"}, + {"type": "alias", "userId": "new-id", "previousId": "old-id"}, + {"type": "group", "userId": "user-1", "groupId": "group-1", "traits": {"plan": "pro"}} + ] + } + ], + "config": { + "flushAt": 15, + "flushInterval": 1000, + "maxRetries": 3, + "timeout": 10 + } +} +``` + +#### Top-level fields + +| Field | Type | Required | Description | +|------------|--------|----------|-------------| +| `writeKey` | string | yes | Segment source write key | +| `apiHost` | string | no | Override the Segment API host (e.g. a local proxy) | +| `sequences`| array | yes | Ordered list of event sequences | +| `config` | object | no | SDK tuning parameters (see below) | + +#### `config` fields + +| Field | Type | Default | Description | +|-----------------|------|---------|-------------| +| `flushAt` | int | 15 | Flush after this many events | +| `flushInterval` | int | 10000 | Flush interval in **milliseconds** | +| `maxRetries` | int | 3 | (informational, not yet wired to SDK) | +| `timeout` | int | 10 | (informational) | + +#### Event fields + +All events share a `type` field. Additional fields per type: + +| Type | Required fields | Optional fields | +|------------|--------------------------|-----------------| +| `identify` | `userId` | `traits` (object) | +| `track` | `userId`, `event` | `properties` (object) | +| `page` | `userId`, `name` | `category`, `properties` | +| `screen` | `userId`, `name` | `category`, `properties` | +| `alias` | `userId` (new id), `previousId` | — | +| `group` | `userId`, `groupId` | `traits` (object) | + +### Output JSON format + +Written to **stdout** on the last line: + +```json +{"success": true, "sentBatches": 1} +``` + +On failure: + +```json +{"success": false, "sentBatches": 0, "error": "description of the error"} +``` + +The process exits with code `0` on success and `1` on failure. + +Debug information is written to **stderr**. + +## Running the full E2E test suite + +```bash +# Clone the test framework next to this repo +git clone https://github.com/segmentio/sdk-e2e-tests ../sdk-e2e-tests + +# Run all suites defined in e2e-config.json +./e2e-cli/run-e2e.sh + +# Pass extra arguments to run-tests.sh +./e2e-cli/run-e2e.sh --suite basic + +# Use a custom test-framework location +E2E_TESTS_DIR=/path/to/sdk-e2e-tests ./e2e-cli/run-e2e.sh +``` diff --git a/e2e-cli/e2e-cli.csproj b/e2e-cli/e2e-cli.csproj new file mode 100644 index 0000000..930b1f4 --- /dev/null +++ b/e2e-cli/e2e-cli.csproj @@ -0,0 +1,16 @@ + + + + Exe + net6.0 + enable + enable + e2e-cli + E2eCli + + + + + + + diff --git a/e2e-cli/e2e-config.json b/e2e-cli/e2e-config.json new file mode 100644 index 0000000..47dd750 --- /dev/null +++ b/e2e-cli/e2e-config.json @@ -0,0 +1,7 @@ +{ + "sdk": "csharp", + "test_suites": "basic,retry", + "auto_settings": false, + "patch": null, + "env": {} +} diff --git a/e2e-cli/run-e2e.sh b/e2e-cli/run-e2e.sh new file mode 100755 index 0000000..fbfb64e --- /dev/null +++ b/e2e-cli/run-e2e.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# +# Run E2E tests for Analytics-CSharp +# +# Prerequisites: Node.js 18+ and .NET 6+ +# +# 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-CSharp e2e-cli ===" +echo "Using dotnet: $(dotnet --version)" + +# Build the e2e-cli +cd "$SCRIPT_DIR" +dotnet build -c Release -o build + +echo "" + +# Run tests +cd "$E2E_DIR" +./scripts/run-tests.sh \ + --sdk-dir "$SCRIPT_DIR" \ + --cli "dotnet $SCRIPT_DIR/build/e2e-cli.dll" \ + "$@" From bbc1fcf14cef83946689bb39d5be1e267c0d399f Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Fri, 8 May 2026 15:16:24 -0400 Subject: [PATCH 2/3] Fix e2e-cli: target net10.0, pass cdnHost, override SegmentURL scheme --- e2e-cli/Program.cs | 57 +++++++++++++++++++++++++++++++++++++++--- e2e-cli/e2e-cli.csproj | 2 +- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/e2e-cli/Program.cs b/e2e-cli/Program.cs index a09cd45..c2f1c31 100644 --- a/e2e-cli/Program.cs +++ b/e2e-cli/Program.cs @@ -43,12 +43,13 @@ return; // unreachable, satisfies compiler } -JsonElement root = doc.RootElement; +System.Text.Json.JsonElement root = doc.RootElement; string writeKey = root.GetProperty("writeKey").GetString() ?? throw new InvalidOperationException("writeKey is required"); string? apiHost = root.TryGetProperty("apiHost", out var apiHostEl) ? apiHostEl.GetString() : null; +string? cdnHost = root.TryGetProperty("cdnHost", out var cdnHostEl) ? cdnHostEl.GetString() : apiHost; // config block (optional) int flushAt = 15; @@ -69,13 +70,42 @@ var errorHandler = new CapturingErrorHandler(errors); // ── Build configuration ────────────────────────────────────────────────────── + +// Determine scheme from apiHost so we can override SegmentURL for http:// targets +// (the SDK always prepends "https://" by default). +// Determine scheme and strip it — the SDK prepends scheme via SegmentURL, +// which we override in PlainHttpClient to respect http:// targets. +string scheme = "https://"; +string? rawApiHost = apiHost; +string? rawCdnHost = cdnHost; + +if (apiHost != null && apiHost.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) +{ + scheme = "http://"; + rawApiHost = apiHost.Substring("http://".Length).TrimEnd('/'); +} +else if (apiHost != null && apiHost.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) +{ + rawApiHost = apiHost.Substring("https://".Length).TrimEnd('/'); +} + +// Strip scheme from cdnHost too (same PlainHttpClient handles it) +if (cdnHost != null && cdnHost.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + rawCdnHost = cdnHost.Substring("http://".Length).TrimEnd('/'); +else if (cdnHost != null && cdnHost.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + rawCdnHost = cdnHost.Substring("https://".Length).TrimEnd('/'); + +var httpClientProvider = new PlainHttpClientProvider(scheme); + var configBuilder = new Configuration( writeKey, flushAt: flushAt, flushInterval: flushInterval, analyticsErrorHandler: errorHandler, storageProvider: new InMemoryStorageProvider(), - apiHost: apiHost + apiHost: rawApiHost, + cdnHost: rawCdnHost, + httpClientProvider: httpClientProvider ); Console.Error.WriteLine($"[e2e-cli] Initialising analytics (writeKey={writeKey[..Math.Min(8, writeKey.Length)]}…, apiHost={apiHost ?? "default"})"); @@ -226,7 +256,7 @@ // ── Helpers ─────────────────────────────────────────────────────────────────── -static JsonObject? GetJsonObject(JsonElement parent, string key) +static JsonObject? GetJsonObject(System.Text.Json.JsonElement parent, string key) { if (!parent.TryGetProperty(key, out var el) || el.ValueKind == JsonValueKind.Null) return null; @@ -246,6 +276,27 @@ static string Escape(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", ""); +// ── HTTP client that respects the scheme (http:// vs https://) ─────────────── + +class PlainHttpClient : Segment.Analytics.Utilities.DefaultHTTPClient +{ + private readonly string _scheme; + public PlainHttpClient(string apiKey, string scheme, string? apiHost, string? cdnHost) + : base(apiKey, apiHost, cdnHost) => _scheme = scheme; + + public override string SegmentURL(string host, string path) => _scheme + host + path; +} + +class PlainHttpClientProvider : Segment.Analytics.Utilities.IHTTPClientProvider +{ + private readonly string _scheme; + public PlainHttpClientProvider(string scheme) => _scheme = scheme; + + public Segment.Analytics.Utilities.HTTPClient CreateHTTPClient( + string apiKey, string? apiHost = null, string? cdnHost = null) + => new PlainHttpClient(apiKey, _scheme, apiHost, cdnHost); +} + // ── Error handler implementation ────────────────────────────────────────────── class CapturingErrorHandler : IAnalyticsErrorHandler diff --git a/e2e-cli/e2e-cli.csproj b/e2e-cli/e2e-cli.csproj index 930b1f4..8ea4c6e 100644 --- a/e2e-cli/e2e-cli.csproj +++ b/e2e-cli/e2e-cli.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net10.0 enable enable e2e-cli From 2b856d98341683417d6af7e0b02c114277c507c9 Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Fri, 8 May 2026 15:35:49 -0400 Subject: [PATCH 3/3] Add e2e workflows and restrict test suites to basic --- .github/workflows/e2e-tests.yml | 61 +++++++++++++++++++++++++++ .github/workflows/publish-e2e-cli.yml | 34 +++++++++++++++ e2e-cli/e2e-config.json | 2 +- 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/e2e-tests.yml create mode 100644 .github/workflows/publish-e2e-cli.yml diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..a24a172 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,61 @@ +name: E2E Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + 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 .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Build e2e-cli + working-directory: sdk/e2e-cli + run: dotnet build -c Release -o build + + - name: Run E2E tests + working-directory: sdk-e2e-tests + run: | + ./scripts/run-tests.sh \ + --sdk-dir "${{ github.workspace }}/sdk/e2e-cli" \ + --cli "dotnet ${{ github.workspace }}/sdk/e2e-cli/build/e2e-cli.dll" + + - 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..3581f5d --- /dev/null +++ b/.github/workflows/publish-e2e-cli.yml @@ -0,0 +1,34 @@ +name: Publish E2E CLI + +on: + push: + branches: [main] + paths: + - 'e2e-cli/**' + - 'Analytics-CSharp/**' + schedule: + - cron: '0 0 1 * *' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout SDK + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Build e2e-cli + working-directory: e2e-cli + run: dotnet build -c Release -o build + + - name: Upload CLI artifact + uses: actions/upload-artifact@v4 + with: + name: e2e-cli-csharp + path: e2e-cli/build/ + retention-days: 90 diff --git a/e2e-cli/e2e-config.json b/e2e-cli/e2e-config.json index 47dd750..eea1cca 100644 --- a/e2e-cli/e2e-config.json +++ b/e2e-cli/e2e-config.json @@ -1,6 +1,6 @@ { "sdk": "csharp", - "test_suites": "basic,retry", + "test_suites": "basic", "auto_settings": false, "patch": null, "env": {}