From 99fc727afa33f9f45b689ea43783eb8a2b8ef73a Mon Sep 17 00:00:00 2001 From: Michael Grosse Huelsewiesche Date: Thu, 6 Mar 2025 12:14:45 -0500 Subject: [PATCH 1/2] Adding a CLI for doing e2e tests --- e2ecli/Package.swift | 24 +++ e2ecli/Sources/e2ecli/main.swift | 314 +++++++++++++++++++++++++++++++ e2ecli/config_schema.json | 100 ++++++++++ e2ecli/testfiles/test1.json | 11 ++ 4 files changed, 449 insertions(+) create mode 100644 e2ecli/Package.swift create mode 100644 e2ecli/Sources/e2ecli/main.swift create mode 100644 e2ecli/config_schema.json create mode 100644 e2ecli/testfiles/test1.json diff --git a/e2ecli/Package.swift b/e2ecli/Package.swift new file mode 100644 index 0000000..3e80e1b --- /dev/null +++ b/e2ecli/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version:5.3 +import PackageDescription + +let package = Package( + name: "e2ecli", + platforms: [ + .macOS(.v10_15) + ], + products: [ + .executable(name: "e2ecli", targets: ["e2ecli"]), + ], + dependencies: [ + .package(name: "Segment", path: "../../analytics-swift"), + ], + targets: [ + .target( + name: "e2ecli", + dependencies: [ + "Segment" + ] + ), + + ] +) \ No newline at end of file diff --git a/e2ecli/Sources/e2ecli/main.swift b/e2ecli/Sources/e2ecli/main.swift new file mode 100644 index 0000000..827a554 --- /dev/null +++ b/e2ecli/Sources/e2ecli/main.swift @@ -0,0 +1,314 @@ +#!/usr/bin/env swift +// +// main.swift +// +// A command‐line tool to test Segment Analytics‑Swift SDK actions from a JSON file. +// + +import Foundation +import Segment + +var writeKey: String = "" +var apiHost: String = "" +var analytics: Analytics? = nil + +// MARK: - Action Handlers + +func processConfigure(_ action: [String: Any]) { + print("writeKey: \(writeKey)") + print("apihost: \(apiHost)") + let config = Configuration(writeKey: writeKey) + var waitUntilStarted = true + + // Set apiHost from command line argument or environment variable. May be overridden by test. + if !apiHost.isEmpty { + config.apiHost(apiHost) + } + + for option in action { + switch option.key { + case "action", "writekey": + // Ignore, already handled. + continue + case "flushAt": + if let flushAt = option.value as? Int { + config.flushAt(flushAt) + } + case "flushInterval": + if let flushInterval = option.value as? TimeInterval { + config.flushInterval(flushInterval) + } + case "trackApplicationLifecycleEvents": + if let flag = option.value as? Bool { + config.trackApplicationLifecycleEvents(flag) + } + case "autoAddSegmentDestination": + if let flag = option.value as? Bool { + config.autoAddSegmentDestination(flag) + } + case "apiHost": + if let apiHost = option.value as? String { + config.apiHost(apiHost) + } + case "cdnHost": + if let cdnHost = option.value as? String { + config.cdnHost(cdnHost) + } + case "operatingMode": + if let modeString = option.value as? String { + if modeString.lowercased() == "synchronous" { + config.operatingMode(.synchronous) + } else { + config.operatingMode(.asynchronous) + } + } + case "userAgent": + if let userAgent = option.value as? String { + config.userAgent(userAgent) + } + case "jsonNonConformingNumberStrategy": + if let strategy = option.value as? String { + switch strategy.lowercased() { + case "zero": + config.jsonNonConformingNumberStrategy(.zero) + case "throw": + config.jsonNonConformingNumberStrategy(.throw) + case "null": + config.jsonNonConformingNumberStrategy(.null) + default: + print("Unsupported jsonNonConformingNumberStrategy: \(strategy)") + } + } + case "storageMode": + if let modeValue = option.value as? String { + switch modeValue.lowercased() { + case "disk": + config.storageMode(.disk) + default: + print("Unsupported storageMode string value: \(modeValue)") + } + } else if let memoryCount = option.value as? Int { + config.storageMode(.memory(memoryCount)) + } + case "waitUntilStarted": + // Not a config option but a behavior modification - default true, can set false for specific tests + if let wait = option.value as? Bool { + waitUntilStarted = wait + } + default: + print("Unknown option: \(option.key)") + } + } + + analytics = Analytics(configuration: config) + if waitUntilStarted { + analytics?.waitUntilStarted() + } + if let analytics = analytics { + print("Configured analytics: \(analytics)") + } else { + print("Failed to configure analytics.") + } +} + +// Process an 'identify' action. +func processIdentify(_ action: [String: Any]) { + guard let userId = action["userId"] as? String else { + print("Missing userId in identify action.") + return + } + // Optional traits dictionary. + let traits = action["traits"] as? [String: Any] + print("Identifying userId: \(userId)") + analytics?.identify(userId: userId, traits: traits) +} + +// Process a 'track' action. +func processTrack(_ action: [String: Any]) { + guard let event = action["event"] as? String else { + print("Missing event in track action.") + return + } + let properties = action["properties"] as? [String: Any] + print("Tracking event: \(event)") + analytics?.track(name: event, properties: properties) +} + +// Process a 'screen' action. +func processScreen(_ action: [String: Any]) { + guard let name = action["name"] as? String else { + print("Missing name in screen action.") + return + } + let category = action["category"] as? String + let properties = action["properties"] as? [String: Any] + print("Screening with name: \(name)") + analytics?.screen(title: name, category: category, properties: properties) +} + +func processGroup(_ action: [String: Any]) { + guard let groupId = action["groupId"] as? String else { + print("Missing groupId in group action.") + return + } + print("Grouping with groupId: \(groupId)") + analytics?.group(groupId: groupId) +} + +func processAlias(_ action: [String: Any]) { + guard let alias = action["newId"] as? String else { + print("Missing newId in alias action.") + return + } + print("Alias to newId: \(alias)") + analytics?.alias(newId: alias) +} + +func processFlush(_ action: [String: Any]) { + if let wait = action["wait"] as? Bool, wait { + let semaphore = DispatchSemaphore(value: 0) + analytics?.flush { + semaphore.signal() + } + let timeout = DispatchTime.now() + .seconds(10) + if semaphore.wait(timeout: timeout) == .timedOut { + print("Flush timed out.") + } + print("Flush completed.") + } else { + analytics?.flush { + print("Flush completed.") + } + print("Flush scheduled.") + } +} + +func processEnabled(_ action: [String: Any]) { + guard let enabled = action["enabled"] as? Bool else { + print("Missing enabled in enabled action.") + return + } + print("Setting enabled to: \(enabled)") + analytics?.enabled = enabled +} + +func processWait(_ action: [String: Any]) { + guard let seconds = action["seconds"] as? Int else { + print("Missing seconds in wait action.") + return + } + print("Waiting for \(seconds) seconds.") + sleep(UInt32(seconds)) +} + +// Process one generic action according to its type. +func processAction(_ action: [String: Any]) { + guard let actionType = action["action"] as? String else { + if let comment = action["comment"] as? String { + print("Comment: \(comment)") + } else { + print("Missing action type in action: \(action)") + } + return + } + + switch actionType.lowercased() { + case "configure": + processConfigure(action) + case "identify": + processIdentify(action) + case "track": + processTrack(action) + case "screen", "page": + processScreen(action) + case "group": + processGroup(action) + case "alias": + processAlias(action) + case "flush": + processFlush(action) + case "enabled": + processEnabled(action) + case "reset": + analytics?.reset() + case "purgeStorage": + analytics?.purgeStorage() + case "waitUntilStarted": // Only useful if `waitUntilStarted` is set to false in the configuration. + analytics?.waitUntilStarted() + case "wait": + processWait(action) + default: + print("Unknown action: \(actionType)") + } +} + +// MARK: - Main Program +Telemetry.shared.enable = false +Analytics.debugLogsEnabled = true + +// Ensure the JSON filename is passed as a command line argument. +guard CommandLine.arguments.count >= 2 else { + print("Usage: \(CommandLine.arguments[0]) path/to/actions.json [-wWRITEKEY] [-aAPIHOST]") + exit(1) +} + +// Get the JSON file path from the command line arguments. +let jsonFilePath = CommandLine.arguments[1] +let fileUrl = URL(fileURLWithPath: jsonFilePath) + +// Get the writeKey and apiHost from the command line arguments or environment variable. +writeKey = "" +apiHost = "" +for argument in CommandLine.arguments { + if argument.hasPrefix("-w") { + writeKey = String(argument.dropFirst(2)) + } else if argument.hasPrefix("-a") { + apiHost = String(argument.dropFirst(2)) + } +} + +// Get the writeKey and apiHost from environment variables if not provided as command line arguments. +if writeKey.isEmpty, let envWriteKey = ProcessInfo.processInfo.environment["E2E_WRITEKEY"] { + writeKey = envWriteKey +} + +if apiHost.isEmpty, let envApiHost = ProcessInfo.processInfo.environment["E2E_APIHOST"] { + apiHost = envApiHost +} + +if writeKey.isEmpty { + print("Missing writeKey. Provide it as a command line argument with -wWRITEKEY or set the E2E_WRITEKEY environment variable.") + exit(1) +} + +do { + let jsonData = try Data(contentsOf: fileUrl) + // Expecting the JSON file to contain an array of actions: + // [ + // { "action": "identify", "userId": "user123", "traits": {"email": "test@example.com"} }, + // { "action": "track", "event": "Item Purchased", "properties": {"item": "book", "price": 10} }, + // { "action": "screen", "name": "Home", "category": "Landing", "properties": {"title": "Welcome"} }, + // { "action": "group", "groupId": "group123" } + // ] + guard + let jsonArray = try JSONSerialization.jsonObject(with: jsonData, options: []) + as? [[String: Any]] + else { + print("JSON file does not contain an array of actions") + exit(1) + } + + // Process each action in the order received. + for action in jsonArray { + processAction(action) + // Optionally flush after each action if needed: + // Analytics.sharedInstance.flush() + } + +} catch { + print("Error reading or parsing the JSON file: \(error)") + exit(1) +} + +print("All actions processed.") +// End of main.swift diff --git a/e2ecli/config_schema.json b/e2ecli/config_schema.json new file mode 100644 index 0000000..115c265 --- /dev/null +++ b/e2ecli/config_schema.json @@ -0,0 +1,100 @@ +{ + "type": "object", + "properties": { + "writeKey": { + "type": "string", + "description": "Your Segment write key value" + }, + "application": { + "type": ["object", "null"], + "description": "A reference to your application" + }, + "collectDeviceId": { + "type": "boolean", + "description": "Collect deviceId, defaults to false" + }, + "trackApplicationLifecycleEvents": { + "type": "boolean", + "description": "Automatically send track for Lifecycle events, defaults to false" + }, + "useLifecycleObserver": { + "type": "boolean", + "description": "Enables the use of LifecycleObserver to track Application lifecycle events, defaults to false" + }, + "trackDeepLinks": { + "type": "boolean", + "description": "Automatically track deep links opened based on intents, defaults to false" + }, + "flushAt": { + "type": "integer", + "description": "Count of events at which we flush events, defaults to 20" + }, + "flushInterval": { + "type": "number", + "description": "Interval in seconds at which we flush events, defaults to 30 seconds" + }, + "defaultSettings": { + "type": ["object", "null"], + "description": "Settings object that will be used as fallback in case of network failure, defaults to empty" + }, + "autoAddSegmentDestination": { + "type": "boolean", + "description": "Automatically add SegmentDestination plugin, defaults to true" + }, + "apiHost": { + "type": "string", + "description": "Set a default apiHost to which Segment sends events, defaults to api.segment.io/v1" + }, + "cdnHost": { + "type": "string", + "description": "Set a default cdnHost for settings retrieval, defaults to cdn-settings.segment.com/v1" + }, + "requestFactory": { + "type": ["object", "null"], + "description": "A block to call when requests are made" + }, + "errorHandler": { + "type": ["object", "null"], + "description": "A block to be called when an error occurs" + }, + "flushPolicies": { + "type": "array", + "items": { + "type": "object" + }, + "description": "List of flush policies" + }, + "operatingMode": { + "type": "string", + "enum": ["synchronous", "asynchronous"], + "description": "Specifies the operating mode/context" + }, + "flushQueue": { + "type": "object", + "description": "Specify a custom queue to use when performing a flush operation" + }, + "userAgent": { + "type": ["string", "null"], + "description": "Specify a custom UserAgent string" + }, + "jsonNonConformingNumberStrategy": { + "type": "string", + "enum": ["zero", "string"], + "description": "Specifies how NaN/Infinity are handled when encoding JSON" + }, + "storageMode": { + "type": "string", + "enum": ["disk", "diskAtURL", "memory", "custom"], + "description": "Specifies the storage mode to be used for events" + }, + "anonymousIdGenerator": { + "type": "object", + "description": "Specify a custom anonymousId generator" + }, + "httpSession": { + "type": "object", + "description": "Use a custom HTTP session" + } + }, + "required": ["writeKey"] + } \ No newline at end of file diff --git a/e2ecli/testfiles/test1.json b/e2ecli/testfiles/test1.json new file mode 100644 index 0000000..9039cf6 --- /dev/null +++ b/e2ecli/testfiles/test1.json @@ -0,0 +1,11 @@ +[ + { "comment": "The most generic of tests"}, + { "action": "configure"}, + { "action": "identify", "userId": "user123", "traits": {"email": "test@example.com", "plan": "premium"} }, + { "action": "track", "event": "Item Purchased", "properties": {"item": "Book", "price": 10} }, + { "action": "page", "name": "Home", "category": "Landing", "properties": {"title": "Welcome"} }, + { "action": "wait", "seconds": 3}, + { "action": "group", "groupId": "group123", "traits": {"name": "Initech", "employees": 125} }, + { "action": "alias", "newId": "user456" }, + { "action": "flush", "wait": true} +] \ No newline at end of file From 835ee0070ae5a7284daa2a9b33f4a05e04282fd7 Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Thu, 6 Mar 2025 09:20:06 -0800 Subject: [PATCH 2/2] get messageId's of events. --- e2ecli/Sources/e2ecli/main.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/e2ecli/Sources/e2ecli/main.swift b/e2ecli/Sources/e2ecli/main.swift index 827a554..95f27f5 100644 --- a/e2ecli/Sources/e2ecli/main.swift +++ b/e2ecli/Sources/e2ecli/main.swift @@ -101,6 +101,15 @@ func processConfigure(_ action: [String: Any]) { } analytics = Analytics(configuration: config) + + var messageIds = [String]() + analytics?.add { event in + if let eventMessageId = event?.messageId { + messageIds.append(eventMessageId) + } + return event + } + if waitUntilStarted { analytics?.waitUntilStarted() }