diff --git a/e2e-cli/e2e-config.json b/e2e-cli/e2e-config.json index 443a40dbf..1098c2f20 100644 --- a/e2e-cli/e2e-config.json +++ b/e2e-cli/e2e-config.json @@ -1,7 +1,9 @@ { "sdk": "react-native", - "test_suites": "basic,settings", + "test_suites": "basic,retry,settings", "auto_settings": true, "patch": null, - "env": {} + "env": { + "BROWSER_BATCHING": "true" + } } diff --git a/e2e-cli/src/cli.ts b/e2e-cli/src/cli.ts index 6f704d0e5..a23a33047 100644 --- a/e2e-cli/src/cli.ts +++ b/e2e-cli/src/cli.ts @@ -1,8 +1,8 @@ /** * E2E CLI for React Native analytics SDK testing * - * Runs the real SDK pipeline (SegmentClient → Timeline → SegmentDestination → - * QueueFlushingPlugin → uploadEvents) with stubs for React Native runtime + * Runs the real SDK pipeline (SegmentClient -> Timeline -> SegmentDestination -> + * QueueFlushingPlugin -> uploadEvents) with stubs for React Native runtime * dependencies so everything executes on Node.js. * * Usage: @@ -75,13 +75,106 @@ const MemoryPersistor: Persistor = { }; // ============================================================================ -// Main CLI Logic +// Helper Functions +// ============================================================================ + +function buildConfig(input: CLIInput): Config { + return { + writeKey: input.writeKey, + trackAppLifecycleEvents: false, + trackDeepLinks: false, + autoAddSegmentDestination: true, + storePersistor: MemoryPersistor, + storePersistorSaveDelay: 0, + ...(input.apiHost && { proxy: input.apiHost, useSegmentEndpoints: true }), + ...(input.cdnHost && { + cdnProxy: input.cdnHost, + useSegmentEndpoints: true, + }), + defaultSettings: { + integrations: { + 'Segment.io': { + apiKey: input.writeKey, + apiHost: 'api.segment.io/v1', + }, + }, + }, + ...(input.config?.flushAt !== undefined && { + flushAt: input.config.flushAt, + }), + flushInterval: input.config?.flushInterval ?? 0.1, + ...(input.config?.maxRetries !== undefined && { + httpConfig: { + rateLimitConfig: { maxRetryCount: input.config.maxRetries }, + backoffConfig: { maxRetryCount: input.config.maxRetries }, + }, + }), + }; +} + +async function dispatchEvent( + client: SegmentClient, + evt: AnalyticsEvent +): Promise { + switch (evt.type) { + case 'track': + if (!evt.event) { + console.warn(`[e2e] skipping track: missing event name`); + return; + } + await client.track(evt.event, evt.properties as JsonMap | undefined); + break; + case 'identify': + await client.identify(evt.userId, evt.traits as JsonMap | undefined); + break; + case 'screen': + case 'page': + if (!evt.name) { + console.warn(`[e2e] skipping ${evt.type}: missing name`); + return; + } + await client.screen(evt.name, evt.properties as JsonMap | undefined); + break; + case 'group': + if (!evt.groupId) { + console.warn(`[e2e] skipping group: missing groupId`); + return; + } + await client.group(evt.groupId, evt.traits as JsonMap | undefined); + break; + case 'alias': + if (!evt.userId) { + console.warn(`[e2e] skipping alias: missing userId`); + return; + } + await client.alias(evt.userId); + break; + default: + console.warn(`[e2e] skipping event: unknown type "${evt.type}"`); + } +} + +/** Polls pendingEvents() until the queue is empty or timeout. Returns true if drained. */ +async function waitForQueueDrain( + client: SegmentClient, + timeoutMs = 30_000 +): Promise { + const pollMs = 50; + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if ((await client.pendingEvents()) === 0) return true; + await new Promise((r) => setTimeout(r, pollMs)); + } + return false; +} + +// ============================================================================ +// Main // ============================================================================ async function main() { const args = process.argv.slice(2); let inputStr: string | undefined; - for (let i = 0; i < args.length; i++) { if (args[i] === '--input' && i + 1 < args.length) { inputStr = args[i + 1]; @@ -91,11 +184,7 @@ async function main() { if (!inputStr) { console.log( - JSON.stringify({ - success: false, - error: 'No input provided', - sentBatches: 0, - }) + JSON.stringify({ success: false, error: 'No input provided', sentBatches: 0 }) ); process.exit(1); } @@ -104,217 +193,55 @@ async function main() { try { const input: CLIInput = JSON.parse(inputStr); + const config = buildConfig(input); - // Build SDK config - const config: Config = { - writeKey: input.writeKey, - trackAppLifecycleEvents: false, - trackDeepLinks: false, - autoAddSegmentDestination: true, - storePersistor: MemoryPersistor, - storePersistorSaveDelay: 0, - // When apiHost is provided (mock tests), use proxy to direct events there - ...(input.apiHost && { - proxy: input.apiHost, - useSegmentEndpoints: true, - }), - // When cdnHost is provided (mock tests), use cdnProxy to direct CDN requests there - ...(input.cdnHost && { - cdnProxy: input.cdnHost, - useSegmentEndpoints: true, - }), - // Provide default settings so SDK doesn't require CDN response - defaultSettings: { - integrations: { - 'Segment.io': { - apiKey: input.writeKey, - apiHost: 'api.segment.io/v1', - }, - }, - }, - ...(input.config?.flushAt !== undefined && { - flushAt: input.config.flushAt, - }), - ...(input.config?.flushInterval !== undefined && { - flushInterval: input.config.flushInterval, - }), - }; - - // Create storage with in-memory persistor const store = new SovranStorage({ storeId: input.writeKey, storePersistor: MemoryPersistor, storePersistorSaveDelay: 0, }); - - // Suppress SDK internal logs to keep E2E test output clean. - // CLI-level warnings/errors still surface via console.warn/console.error. const logger = new Logger(true); const client = new SegmentClient({ config, logger, store }); - - // Initialize — adds plugins, resolves settings, processes pending events await client.init(); - // Process event sequences for (const sequence of input.sequences) { if (sequence.delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, sequence.delayMs)); } - for (const evt of sequence.events) { - // Validate event has a type - if (!evt.type) { - console.warn('[WARN] Skipping event: missing event type', evt); - continue; - } - - try { - switch (evt.type) { - case 'track': { - // Required: event name - if (!evt.event || typeof evt.event !== 'string') { - console.warn( - `[WARN] Skipping track event: missing or invalid event name`, - evt - ); - continue; - } - - // Optional: properties (validate if present) - const properties = evt.properties as JsonMap | undefined; - if ( - evt.properties !== undefined && - (evt.properties === null || - Array.isArray(evt.properties) || - typeof evt.properties !== 'object') - ) { - console.warn( - `[WARN] Track event "${evt.event}" has invalid properties, proceeding without them` - ); - } - - await client.track(evt.event, properties); - break; - } - - case 'identify': { - // Optional userId (Segment allows anonymous identify) - // Optional traits (validate if present) - const traits = evt.traits as JsonMap | undefined; - if ( - evt.traits !== undefined && - (evt.traits === null || - Array.isArray(evt.traits) || - typeof evt.traits !== 'object') - ) { - console.warn( - `[WARN] Identify event has invalid traits, proceeding without them` - ); - } - - await client.identify(evt.userId, traits); - break; - } - - case 'screen': - case 'page': { - // RN SDK has no page(); map to screen for cross-SDK test compat - // Required: screen/page name - if (!evt.name || typeof evt.name !== 'string') { - console.warn( - `[WARN] Skipping ${evt.type} event: missing or invalid name`, - evt - ); - continue; - } - - // Optional: properties (validate if present) - const properties = evt.properties as JsonMap | undefined; - if ( - evt.properties !== undefined && - (evt.properties === null || - Array.isArray(evt.properties) || - typeof evt.properties !== 'object') - ) { - console.warn( - `[WARN] Screen "${evt.name}" has invalid properties, proceeding without them` - ); - } - - await client.screen(evt.name, properties); - break; - } - - case 'group': { - // Required: groupId - if (!evt.groupId || typeof evt.groupId !== 'string') { - console.warn( - `[WARN] Skipping group event: missing or invalid groupId`, - evt - ); - continue; - } - - // Optional: traits (validate if present) - const traits = evt.traits as JsonMap | undefined; - if ( - evt.traits !== undefined && - (evt.traits === null || - Array.isArray(evt.traits) || - typeof evt.traits !== 'object') - ) { - console.warn( - `[WARN] Group event for "${evt.groupId}" has invalid traits, proceeding without them` - ); - } - - await client.group(evt.groupId, traits); - break; - } - - case 'alias': { - // Required: userId - if (!evt.userId || typeof evt.userId !== 'string') { - console.warn( - `[WARN] Skipping alias event: missing or invalid userId`, - evt - ); - continue; - } - - await client.alias(evt.userId); - break; - } - - default: - console.warn( - `[WARN] Skipping event: unknown event type "${evt.type}"`, - evt - ); - continue; - } - } catch (error) { - // Log but don't fail the entire sequence if one event fails - console.error( - `[ERROR] Failed to process ${evt.type} event:`, - error, - evt - ); - continue; - } + await dispatchEvent(client, evt); } } - // Flush all queued events through the real pipeline await client.flush(); + const drained = await waitForQueueDrain(client); + const permanentDropCount = client.droppedEvents(); - // Brief delay to let async upload operations settle - await new Promise((resolve) => setTimeout(resolve, 500)); + const finalPending = drained ? 0 : await client.pendingEvents(); + const totalEvents = input.sequences.reduce( + (sum, seq) => sum + seq.events.length, + 0 + ); + const delivered = Math.max( + 0, + totalEvents - finalPending - permanentDropCount + ); + // Approximate: SDK doesn't expose actual batch count, so we derive it + // from delivered event count and configured batch size. + const sentBatches = + delivered > 0 + ? Math.ceil(delivered / Math.max(1, input.config?.flushAt ?? 1)) + : 0; + const success = finalPending === 0 && permanentDropCount === 0; client.cleanup(); - - // sentBatches: SDK doesn't expose batch count tracking - output = { success: true, sentBatches: 0 }; + output = { + success, + sentBatches, + ...(permanentDropCount > 0 && { + error: `${permanentDropCount} events permanently dropped`, + }), + }; } catch (e) { const error = e instanceof Error ? e.message : String(e); output = { success: false, error, sentBatches: 0 }; diff --git a/e2e-cli/tsconfig.json b/e2e-cli/tsconfig.json index 49513ad2d..f8eeca9c7 100644 --- a/e2e-cli/tsconfig.json +++ b/e2e-cli/tsconfig.json @@ -6,6 +6,7 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, + "jsx": "react", "forceConsistentCasingInFileNames": true, // Note: TypeScript is used only for type-checking; build output is generated by esbuild (see build.js). "noEmit": true, diff --git a/packages/core/src/analytics.ts b/packages/core/src/analytics.ts index 0b496ca3f..c40881751 100644 --- a/packages/core/src/analytics.ts +++ b/packages/core/src/analytics.ts @@ -1048,6 +1048,20 @@ export class SegmentClient { return totalEventsCount; } + + /** + * Method to get count of events permanently dropped by SegmentDestination. + */ + droppedEvents(): number { + let count = 0; + for (const plugin of this.getPlugins()) { + if (plugin instanceof SegmentDestination) { + count += plugin.droppedEventCount; + } + } + return count; + } + private resumeTimeoutId?: ReturnType; private waitingPlugins = new Set(); diff --git a/packages/core/src/plugins/SegmentDestination.ts b/packages/core/src/plugins/SegmentDestination.ts index cc7e911e6..21e9b157a 100644 --- a/packages/core/src/plugins/SegmentDestination.ts +++ b/packages/core/src/plugins/SegmentDestination.ts @@ -22,6 +22,7 @@ export const SEGMENT_DESTINATION_KEY = 'Segment.io'; export class SegmentDestination extends DestinationPlugin { type = PluginType.destination; key = SEGMENT_DESTINATION_KEY; + droppedEventCount = 0; private apiHost?: string; private settingsResolve: () => void; private settingsPromise: Promise;