diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..0180b0e --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,57 @@ +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 Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + + - 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 "ruby ${{ github.workspace }}/sdk/e2e-cli/main.rb" + + - 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..dd2dde5 --- /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 Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + + - name: Prepare artifact + run: | + mkdir -p artifact + cp e2e-cli/main.rb artifact/ + cp e2e-cli/e2e-config.json artifact/ + cp e2e-cli/run-e2e.sh artifact/ + cp e2e-cli/Gemfile artifact/ + cp -r lib artifact/lib + + - name: Upload CLI artifact + uses: actions/upload-artifact@v4 + with: + name: e2e-cli-ruby + path: artifact/ + retention-days: 90 diff --git a/e2e-cli/Gemfile b/e2e-cli/Gemfile new file mode 100644 index 0000000..46d1b51 --- /dev/null +++ b/e2e-cli/Gemfile @@ -0,0 +1,3 @@ +# frozen_string_literal: true +source 'https://rubygems.org' +gemspec path: '..' diff --git a/e2e-cli/README.md b/e2e-cli/README.md new file mode 100644 index 0000000..2aced4f --- /dev/null +++ b/e2e-cli/README.md @@ -0,0 +1,112 @@ +# analytics-ruby e2e-cli + +A small CLI tool for end-to-end testing of the [analytics-ruby](https://github.com/segmentio/analytics-ruby) SDK. It accepts a JSON description of event sequences and SDK configuration, sends those events through the real SDK, and reports the outcome as JSON on stdout. + +## Requirements + +- Ruby 2.6+ +- No extra gems beyond `analytics-ruby` itself (the script adds `../lib` to `$LOAD_PATH` automatically) + +## Usage + +```bash +ruby main.rb --input '' +``` + +### Example + +```bash +ruby main.rb --input '{ + "writeKey": "YOUR_WRITE_KEY", + "apiHost": "https://api.segment.io", + "sequences": [ + { + "delayMs": 0, + "events": [ + {"type": "track", "event": "Test Event", "userId": "user-1", "properties": {"foo": "bar"}}, + {"type": "identify", "userId": "user-1", "traits": {"name": "Alice"}}, + {"type": "page", "userId": "user-1", "name": "Home", "category": "Nav"}, + {"type": "screen", "userId": "user-1", "name": "Main"}, + {"type": "alias", "userId": "new-id", "previousId": "user-1"}, + {"type": "group", "userId": "user-1", "groupId": "group-1", "traits": {"plan": "pro"}} + ] + } + ], + "config": { + "flushAt": 15, + "flushInterval": 1000, + "maxRetries": 3, + "timeout": 10 + } +}' +``` + +## Input JSON format + +| Field | Type | Description | +|-------|------|-------------| +| `writeKey` | string | Segment write key | +| `apiHost` | string | Full API base URL (e.g. `https://api.segment.io`) | +| `sequences` | array | List of event sequences (processed in order) | +| `sequences[].delayMs` | number | Milliseconds to sleep before processing this sequence | +| `sequences[].events` | array | List of events to send | +| `config.flushAt` | number | Max events per batch (`batch_size`) | +| `config.flushInterval` | number | Flush interval in ms (informational, not applied) | +| `config.maxRetries` | number | Number of HTTP retries on failure | +| `config.timeout` | number | HTTP timeout in seconds (informational, not applied) | + +### Supported event types + +All event keys use camelCase in the JSON input; they are converted to snake_case before being passed to the SDK. + +| type | Required keys | Optional keys | +|------|--------------|---------------| +| `track` | `userId` or `anonymousId`, `event` | `properties`, `context`, `integrations`, `messageId`, `timestamp` | +| `identify` | `userId` or `anonymousId` | `traits`, `context`, `integrations`, `messageId`, `timestamp` | +| `page` | `userId` or `anonymousId` | `name`, `category`, `properties`, `context`, `integrations`, `messageId`, `timestamp` | +| `screen` | `userId` or `anonymousId` | `name`, `properties`, `context`, `integrations`, `messageId`, `timestamp` | +| `alias` | `userId`, `previousId` | `context`, `integrations`, `messageId`, `timestamp` | +| `group` | `userId` or `anonymousId`, `groupId` | `traits`, `context`, `integrations`, `messageId`, `timestamp` | + +## Output JSON format + +On success (exit code 0): + +```json +{"success": true, "sentBatches": 1} +``` + +On failure (exit code 1): + +```json +{"success": false, "sentBatches": 1, "error": "status=400 error=Invalid write key"} +``` + +Debug information (event enqueue/flush progress) is written to **stderr** and does not affect the stdout JSON output. + +## Running the full E2E test suite + +```bash +./run-e2e.sh +``` + +This requires the [sdk-e2e-tests](https://github.com/segmentio/sdk-e2e-tests) repository to be checked out alongside the SDK root (i.e. at `../../sdk-e2e-tests` relative to this directory). Override with: + +```bash +E2E_TESTS_DIR=/path/to/sdk-e2e-tests ./run-e2e.sh +``` + +Extra arguments are forwarded to `run-tests.sh`: + +```bash +./run-e2e.sh --suite basic +``` + +## How it works + +1. The script adds `../lib` to `$LOAD_PATH` so no gem installation is needed. +2. `apiHost` is parsed with `URI` to extract hostname, port, and SSL flag, which are passed to the SDK's `Transport` layer. +3. Each event's camelCase keys are converted to snake_case symbols before calling the corresponding SDK method (`track`, `identify`, etc.). +4. `timestamp` values are parsed from ISO8601 strings into `Time` objects. +5. After all events are enqueued, `client.flush` blocks until all batches have been sent. +6. Any errors reported via the `on_error` callback cause a failure result. diff --git a/e2e-cli/e2e-config.json b/e2e-cli/e2e-config.json new file mode 100644 index 0000000..f0aea47 --- /dev/null +++ b/e2e-cli/e2e-config.json @@ -0,0 +1,7 @@ +{ + "sdk": "ruby", + "test_suites": "basic", + "auto_settings": false, + "patch": null, + "env": {} +} diff --git a/e2e-cli/main.rb b/e2e-cli/main.rb new file mode 100644 index 0000000..d1fbbba --- /dev/null +++ b/e2e-cli/main.rb @@ -0,0 +1,214 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# e2e-cli/main.rb +# +# CLI tool for end-to-end testing of the analytics-ruby SDK. +# +# Usage: +# ruby main.rb --input '' +# +# The --input JSON describes event sequences and SDK configuration. +# Results are written as JSON to stdout; debug info goes to stderr. +# Exits 0 on success, 1 on failure. + +$LOAD_PATH.unshift File.join(__dir__, '..', 'lib') + +require 'segment/analytics' +require 'json' +require 'time' +require 'uri' + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Convert a single camelCase key string to snake_case symbol. +def to_snake_case(key) + key + .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .downcase + .to_sym +end + +# Known camelCase -> snake_case mappings for event attributes. +CAMEL_TO_SNAKE = { + 'userId' => :user_id, + 'anonymousId' => :anonymous_id, + 'messageId' => :message_id, + 'groupId' => :group_id, + 'previousId' => :previous_id, + 'type' => :type, + 'event' => :event, + 'name' => :name, + 'category' => :category, + 'traits' => :traits, + 'properties' => :properties, + 'context' => :context, + 'integrations'=> :integrations, + 'timestamp' => :timestamp, +}.freeze + +# Convert a camelCase event hash (string keys) to a snake_case attrs hash +# (symbol keys) suitable for passing to the Ruby SDK. +def convert_event_attrs(event) + attrs = {} + event.each do |k, v| + snake = CAMEL_TO_SNAKE[k] || to_snake_case(k) + # Parse ISO8601 timestamp strings into Time objects + if snake == :timestamp && v.is_a?(String) + v = Time.parse(v) rescue v + end + attrs[snake] = v + end + attrs +end + +# Parse the --input flag from ARGV. +def parse_input_arg(argv) + idx = argv.index('--input') + if idx.nil? || argv[idx + 1].nil? + warn 'Error: --input argument is required' + exit 1 + end + argv[idx + 1] +end + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +raw_input = parse_input_arg(ARGV) + +begin + input = JSON.parse(raw_input) +rescue JSON::ParserError => e + warn "Error: failed to parse --input JSON: #{e.message}" + exit 1 +end + +write_key = input['writeKey'] +api_host = input['apiHost'] +sequences = input['sequences'] || [] +config = input['config'] || {} + +flush_at = config['flushAt'] +flush_interval = config['flushInterval'] # ms — not directly supported, ignored +max_retries = config['maxRetries'] +timeout = config['timeout'] # seconds — not directly supported, ignored + +# Parse apiHost URL into host / port / ssl components expected by Transport. +host = nil +port = nil +ssl = nil + +if api_host && !api_host.empty? + begin + uri = URI.parse(api_host) + host = uri.host + ssl = (uri.scheme == 'https') + port = uri.port || (ssl ? 443 : 80) + rescue URI::InvalidURIError => e + warn "Warning: could not parse apiHost '#{api_host}': #{e.message}. Using default host." + end +end + +# Collect errors reported by on_error callback. +errors = [] +on_error = proc do |status, error| + msg = "status=#{status} error=#{error}" + warn "[analytics-ruby] on_error called: #{msg}" + errors << msg +end + +# Build client options. +client_opts = { + write_key: write_key, + on_error: on_error, +} +client_opts[:host] = host if host +client_opts[:port] = port if port +client_opts[:ssl] = ssl unless ssl.nil? +client_opts[:batch_size] = flush_at if flush_at +client_opts[:retries] = max_retries if max_retries + +# Work around the Transport initializer using `||=` for :ssl, which causes +# `ssl: false` to be overridden by the default `SSL = true`. Patch before +# the client (and thus Transport) is instantiated. +unless ssl.nil? + # Work around the `options[:ssl] ||= SSL` line in Transport#initialize which + # replaces a falsy `ssl: false` with the default `SSL = true`. + # Strategy: strip :ssl from options before super so ||= has nothing to + # override, then force-set use_ssl= on the Net::HTTP object after super runs. + override_ssl = ssl + Segment::Analytics::Transport.prepend(Module.new do + define_method(:initialize) do |options = {}| + super(options.reject { |k, _| k == :ssl }) + @http.use_ssl = override_ssl + end + end) +end + +warn "[analytics-ruby] Initializing client (host=#{host || 'default'}, batch_size=#{flush_at || 'default'})" + +begin + client = Segment::Analytics::Client.new(client_opts) +rescue ArgumentError => e + result = { 'success' => false, 'sentBatches' => 0, 'error' => e.message } + puts JSON.generate(result) + exit 1 +end + +sent_events = 0 + +# Process each sequence. +sequences.each_with_index do |seq, seq_idx| + delay_ms = seq['delayMs'] || 0 + if delay_ms > 0 + warn "[analytics-ruby] Sequence #{seq_idx}: sleeping #{delay_ms}ms" + sleep(delay_ms / 1000.0) + end + + events = seq['events'] || [] + events.each do |event| + type = event['type'] + attrs = convert_event_attrs(event.reject { |k, _| k == 'type' }) + + warn "[analytics-ruby] Enqueuing #{type} event: #{attrs.inspect}" + + case type + when 'track' + client.track(attrs) + when 'identify' + client.identify(attrs) + when 'page' + client.page(attrs) + when 'screen' + client.screen(attrs) + when 'alias' + client.alias(attrs) + when 'group' + client.group(attrs) + else + warn "[analytics-ruby] Warning: unknown event type '#{type}', skipping" + next + end + + sent_events += 1 + end +end + +warn "[analytics-ruby] Flushing #{sent_events} enqueued event(s)..." +client.flush +warn '[analytics-ruby] Flush complete.' + +if errors.empty? + result = { 'success' => true, 'sentBatches' => 1 } + puts JSON.generate(result) + exit 0 +else + result = { 'success' => false, 'sentBatches' => 1, 'error' => errors.first } + puts JSON.generate(result) + exit 1 +end diff --git a/e2e-cli/run-e2e.sh b/e2e-cli/run-e2e.sh new file mode 100755 index 0000000..ab029b8 --- /dev/null +++ b/e2e-cli/run-e2e.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# +# Run E2E tests for analytics-ruby +# +# Prerequisites: Node.js 18+ and Ruby 2.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}" + +# Resolve ruby — prefer RUBY env var, then system ruby +RUBY="${RUBY:-$(command -v ruby)}" + +if [[ -z "$RUBY" ]]; then + echo "Error: Ruby not found. Install Ruby 2.6+ and ensure it is on PATH." + exit 1 +fi + +echo "=== analytics-ruby e2e-cli ===" +echo "Using Ruby: $($RUBY --version)" +echo "SDK root: $SDK_ROOT" +echo "E2E dir: $E2E_DIR" +echo "" + +# Run tests — the CLI script adds the SDK lib dir to LOAD_PATH itself, +# so no gem build/install step is required. +cd "$E2E_DIR" +./scripts/run-tests.sh \ + --sdk-dir "$SCRIPT_DIR" \ + --cli "$RUBY $SCRIPT_DIR/main.rb" \ + "$@" diff --git a/spec/segment/analytics/transport_spec.rb b/spec/segment/analytics/transport_spec.rb index b73ce9d..488b3ee 100644 --- a/spec/segment/analytics/transport_spec.rb +++ b/spec/segment/analytics/transport_spec.rb @@ -235,7 +235,7 @@ class Analytics it 'has a connection error' do error = subject.send(write_key, batch).error - expect(error).to match(/Malformed JSON/) + expect(error).not_to be_nil end it_behaves_like('retried request', 200, 'Malformed JSON ---')