diff --git a/packages/core/src/e2e-helpers.ts b/packages/core/src/e2e-helpers.ts new file mode 100644 index 0000000000..4f4ce06a5d --- /dev/null +++ b/packages/core/src/e2e-helpers.ts @@ -0,0 +1,256 @@ +import type { SegmentEvent } from './segment-event' +import type { + E2EAudienceEventBase, + E2EEngageAudienceEventOptions, + E2EEngageAudienceEvent, + E2EJourneysV1AudienceEventOptions, + E2EJourneysV1AudienceTrackEvent, + E2EJourneysV2AudienceEventOptions, + E2EJourneysV2AudienceTrackEvent, + E2ERetlAudienceEventOptions, + E2ERetlAudienceTrackEvent +} from './e2e-types' + +type E2EEventOverrides = Partial> + +/* + * Regular Segment Connections event. + * + * Overloads enforce the name rules at the type level: + * - track: name required (becomes the event name) + * - page/screen: name optional + * - identify/group/alias: no name accepted + */ +export function createE2EEvent(type: 'track', name: string, overrides?: E2EEventOverrides): SegmentEvent +export function createE2EEvent(type: 'page' | 'screen', name?: string, overrides?: E2EEventOverrides): SegmentEvent +export function createE2EEvent( + type: 'identify' | 'group' | 'alias', + name?: undefined, + overrides?: E2EEventOverrides +): SegmentEvent +export function createE2EEvent(type: SegmentEvent['type'], name?: string, overrides?: E2EEventOverrides): SegmentEvent { + if (type === 'track') { + return { + type, + event: name, + messageId: '$guid', + timestamp: '$now', + ...overrides + } + } + + if (type === 'page' || type === 'screen') { + return { + type, + name, + messageId: '$guid', + timestamp: '$now', + ...overrides + } + } + + if (name) { + throw new Error( + `createE2EEvent: "name" is not supported for "${type}" events. Only track, page, and screen accept a name.` + ) + } + + return { + type, + messageId: '$guid', + timestamp: '$now', + ...overrides + } +} + +function buildAudienceEventBase(options: E2EAudienceEventBase) { + const { + computationKey, + computationId, + externalAudienceId, + userId, + anonymousId, + email, + audienceFields, + includeContextTraits = true + } = options + return { + messageId: '$guid', + timestamp: '$now', + ...(userId && { userId }), + ...(anonymousId && { anonymousId }), + context: { + personas: { + computation_class: 'audience', + computation_key: computationKey, + computation_id: computationId, + ...(externalAudienceId && { external_audience_id: externalAudienceId }) + }, + ...(audienceFields && { audienceFields }), + ...(includeContextTraits && email && { traits: { email } }) + } + } +} + +/* + * Engage Audience event + * Supports identify and track events + */ +export function createE2EEngageAudienceEvent( + options: E2EEngageAudienceEventOptions +): E2EEngageAudienceEvent { + const { action, computationKey, email, enrichedTraits } = options + const membership = action === 'add' + const base = buildAudienceEventBase({ ...options, includeContextTraits: options.type === 'track' }) + + if (options.type === 'track') { + return { + ...base, + type: 'track', + event: options.eventName ?? 'Test Engage Audience Membership Event', + properties: { + [computationKey]: membership, + ...enrichedTraits + } + } as E2EEngageAudienceEvent + } + + return { + ...base, + type: 'identify', + traits: { + [computationKey]: membership, + ...enrichedTraits, + ...(email && { email }) + } + } as E2EEngageAudienceEvent +} + +/* + * Journeys V1 events (preset journeys_step_entered_track) do not have properties[] value. + * All Journeys V1 events enter the user to the audience, never remove them. + * Only track events supported + */ +export function createE2EJourneysV1AudienceEvent( + options: E2EJourneysV1AudienceEventOptions +): E2EJourneysV1AudienceTrackEvent { + const { + computationKey, + computationId, + externalAudienceId, + userId, + anonymousId, + email, + audienceFields, + enrichedTraits + } = options + + const event = { + messageId: '$guid', + timestamp: '$now', + ...(userId && { userId }), + ...(anonymousId && { anonymousId }), + type: 'track', + event: 'Audience Entered', + properties: { + ...enrichedTraits + }, + context: { + personas: { + computation_class: 'journey_step', + computation_key: computationKey, + computation_id: computationId, + ...(externalAudienceId && { external_audience_id: externalAudienceId }) + }, + ...(audienceFields && { audienceFields }), + ...(email && { traits: { email } }) + } + } + + return event as E2EJourneysV1AudienceTrackEvent +} + +/* + * Journeys V2 events use computation_class 'journey_step' (not 'audience') and carry + * journey_context / journey_metadata in properties alongside properties[], + * the membership boolean (true = entering the step / add, false = exiting / remove). + * Only track events supported. + */ +export function createE2EJourneysV2AudienceEvent( + options: E2EJourneysV2AudienceEventOptions +): E2EJourneysV2AudienceTrackEvent { + const { + action = 'add', + computationKey, + computationId, + externalAudienceId, + eventName, + journeyId, + journeyName, + userId, + anonymousId, + email, + audienceFields, + enrichedTraits + } = options + + const membership = action === 'add' + + const event = { + messageId: '$guid', + timestamp: '$now', + ...(userId && { userId }), + ...(anonymousId && { anonymousId }), + type: 'track', + event: eventName ?? 'Test Journeys V2 Audience Membership Event', + properties: { + [computationKey]: membership, + journey_context: { + [computationKey]: {} + }, + journey_metadata: { + epoch_id: '$guid', + journey_id: journeyId ?? 'jver_e2e_journey', + journey_name: journeyName ?? 'e2e journey' + }, + ...enrichedTraits + }, + context: { + personas: { + computation_class: 'journey_step', + computation_key: computationKey, + computation_id: computationId, + ...(externalAudienceId && { external_audience_id: externalAudienceId }) + }, + ...(audienceFields && { audienceFields }), + ...(email && { traits: { email } }) + } + } + + return event as E2EJourneysV2AudienceTrackEvent +} + +/* + * Reverse ETL Audience event + * Same payload structure as Engage track events but uses RETL-specific event names: 'new', 'updated', 'deleted' + * Only track events supported + */ +export function createE2ERetlAudienceEvent( + options: E2ERetlAudienceEventOptions +): E2ERetlAudienceTrackEvent { + const { eventName, computationKey, enrichedTraits } = options + const membership = eventName !== 'deleted' + const base = buildAudienceEventBase(options) + + const event = { + ...base, + type: 'track', + event: eventName, + properties: { + [computationKey]: membership, + ...enrichedTraits + } + } + + return event as E2ERetlAudienceTrackEvent +} diff --git a/packages/core/src/e2e-types.ts b/packages/core/src/e2e-types.ts new file mode 100644 index 0000000000..660f347fc8 --- /dev/null +++ b/packages/core/src/e2e-types.ts @@ -0,0 +1,377 @@ +import type { SegmentEvent } from './segment-event' +import type { JSONObject, JSONValue } from './json-object' + +export type E2EExpectation = E2ESuccessExpectation | E2EFailureExpectation | E2EErrorExpectation + +/** + * The HTTP request was sent and the destination API returned a 2xx response. + */ +export interface E2ESuccessExpectation { + status: 'success' + httpStatus?: E2EHttpSuccessCode + bodyContains?: string + /** Partial deep match against the JSON response body. Arrays must match length and each item is partial-matched. */ + jsonContains?: unknown +} + +/** + * The HTTP request was sent and the destination API returned a non-2xx response. + * Use this to verify that the destination rejects specific inputs (e.g., bad auth, invalid payload). + */ +export interface E2EFailureExpectation { + status: 'failure' + httpStatus: E2EHttpFailureCode + bodyContains?: string + /** Partial deep match against the JSON response body. Arrays must match length and each item is partial-matched. */ + jsonContains?: unknown +} + +/** + * Our action code threw before making an HTTP request. + * The request never left. Use this to verify client-side validation + * (e.g., PayloadValidationError when required fields are missing). + */ +export interface E2EErrorExpectation { + status: 'error' + errorType: string + errorMessage?: string +} + +/** + * Dynamic value markers that the runner resolves at execution time. + * + * - '$now' → current ISO 8601 timestamp (e.g., '2026-05-28T14:32:01.000Z') + * - '$guid' → fresh UUID v4, unique each occurrence + * - '$guid:' → UUID v4, consistent within a single fixture execution. + * All occurrences of the same name resolve to the same value. + * - '$externalAudienceId' → resolved after createAudience step returns the destination's audience ID + */ +export type E2EDynamicValue = '$now' | '$guid' | `$guid:${string}` | '$externalAudienceId' + +export type E2EExecutionMode = 'single' | 'batch' | 'batchWithMultistatus' + +export type E2EFixture = E2ESingleFixture | E2EBatchFixture | E2EBatchWithMultistatusFixture + +export interface E2EBaseFixture { + /** Human-readable name for the test case, shown in runner output. */ + description: string + /** FQL query that determines whether the event matches this subscription. */ + subscribe: string + /** Mapping kit directives that transform the event into the action's payload shape. */ + mapping: JSONObject + /** The expected outcome of executing this fixture. */ + expect: E2EExpectation + /** Hint shown in verbose mode when this fixture fails. Helps developers diagnose common issues. */ + verboseFailureHint?: string + /** Feature flags passed to the action, to exercise flag-gated code branches end-to-end. */ + features?: Record + /** + * Max times the runner re-runs this fixture if it fails, with exponential backoff between attempts. + * Overrides the run-level retry default. Useful for destinations with eventual consistency + * (e.g. writes to a freshly-created audience that briefly return a transient error). + */ + retries?: number +} + +export interface E2ESingleFixture extends E2EBaseFixture { + /** Executes via onEvent() with a single event. */ + mode: 'single' + /** + * The Segment event (track, identify, page, screen, etc.) sent into the action. + * String values may use dynamic markers ($now, $guid, $guid:) that the + * runner resolves before execution. + */ + event: SegmentEvent +} + +export interface E2EBatchFixture extends E2EBaseFixture { + /** Executes via onBatch() with multiple events. Response is a standard HTTP response. */ + mode: 'batch' + /** + * Array of Segment events sent into the action as a batch. + * String values may use dynamic markers ($now, $guid, $guid:) that the + * runner resolves before execution. + */ + events: SegmentEvent[] +} + +export interface E2EBatchWithMultistatusFixture extends E2EBaseFixture { + /** Executes via onBatch(). Response is a per-item MultiStatusResponse array. */ + mode: 'batchWithMultistatus' + /** + * Array of Segment events sent into the action as a batch. + * String values may use dynamic markers ($now, $guid, $guid:) that the + * runner resolves before execution. + */ + events: SegmentEvent[] +} + +export interface E2EAudienceEventBase { + computationKey: string + computationId: string + externalAudienceId?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + includeContextTraits?: boolean +} + +interface E2EEngageAudienceEventOptionsBase { + action: 'add' | 'remove' + computationKey: ComputationKey + computationId: string + externalAudienceId?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + enrichedTraits?: Record +} + +export interface E2EEngageAudienceTrackEventOptions + extends E2EEngageAudienceEventOptionsBase { + type: 'track' + eventName?: string +} + +export interface E2EEngageAudienceIdentifyEventOptions + extends E2EEngageAudienceEventOptionsBase { + type: 'identify' + /** identify events do not carry an event name. */ + eventName?: never +} + +export type E2EEngageAudienceEventOptions = + | E2EEngageAudienceTrackEventOptions + | E2EEngageAudienceIdentifyEventOptions + +export interface E2EJourneysV1AudienceEventOptions { + computationKey: ComputationKey + computationId: string + externalAudienceId?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + enrichedTraits?: Record +} + +export interface E2EJourneysV2AudienceEventOptions { + /** Whether the user is entering ('add') or exiting ('remove') the journey step. Sets properties[computationKey]. Defaults to 'add'. */ + action?: 'add' | 'remove' + computationKey: ComputationKey + computationId: string + externalAudienceId?: string + eventName?: string + journeyId?: string + journeyName?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + enrichedTraits?: Record +} + +export interface E2ERetlAudienceEventOptions { + eventName: 'new' | 'updated' | 'deleted' + computationKey: ComputationKey + computationId: string + externalAudienceId?: string + userId?: string + anonymousId?: string + email?: string + audienceFields?: Record + enrichedTraits?: Record +} + +export interface E2ERetlAudienceTrackEvent extends SegmentEvent { + type: 'track' + event: 'new' | 'updated' | 'deleted' + messageId: string + timestamp: string + context: { + personas: { + computation_class: 'audience' + computation_key: ComputationKey + computation_id: string + external_audience_id?: string + } + traits?: { email?: string } + audienceFields?: Record + } + properties: { [key in ComputationKey]: boolean } & { [k: string]: JSONValue } +} + +export interface E2EJourneysV1AudienceTrackEvent extends SegmentEvent { + type: 'track' + event: 'Audience Entered' + messageId: string + timestamp: string + context: { + personas: { + computation_class: 'journey_step' + computation_key: ComputationKey + computation_id: string + external_audience_id?: string + } + traits?: { email?: string } + audienceFields?: Record + } + properties: { [k: string]: JSONValue } +} + +export interface E2EJourneysV2AudienceTrackEvent extends SegmentEvent { + type: 'track' + event: string + messageId: string + timestamp: string + context: { + personas: { + computation_class: 'journey_step' + computation_key: ComputationKey + computation_id: string + external_audience_id?: string + } + traits?: { email?: string } + audienceFields?: Record + } + properties: { + journey_context: { [k: string]: JSONValue } + journey_metadata: { journey_id: string; journey_name: string; [k: string]: JSONValue } + } & { [key in ComputationKey]: boolean } & { [k: string]: JSONValue } +} + +export interface E2EEngageAudienceTrackEvent extends SegmentEvent { + type: 'track' + event: string + messageId: string + timestamp: string + context: { + personas: { + computation_class: 'audience' + computation_key: ComputationKey + computation_id: string + external_audience_id?: string + } + traits?: { email?: string } + audienceFields?: Record + } + properties: { [key in ComputationKey]: boolean } & { [k: string]: JSONValue } +} + +export interface E2EEngageAudienceIdentifyEvent extends SegmentEvent { + type: 'identify' + messageId: string + timestamp: string + context: { + personas: { + computation_class: 'audience' + computation_key: ComputationKey + computation_id: string + external_audience_id?: string + } + audienceFields?: Record + } + traits: { [key in ComputationKey]: boolean } & { [k: string]: JSONValue } +} + +export type E2EEngageAudienceEvent = + | E2EEngageAudienceTrackEvent + | E2EEngageAudienceIdentifyEvent + +export interface E2ESettingsSecretValue { + $env: string +} + +export interface E2ESettingsObject { + [key: string]: string | number | boolean | E2ESettingsSecretValue | E2ESettingsObject +} + +export interface E2EDestinationConfig { + settings: E2ESettingsObject +} + +export interface E2ETeardownContext { + settings: Record +} + +export interface E2ETeardownAudienceContext extends E2ETeardownContext { + externalAudienceId: string + audienceSettings: Record +} + +export interface E2EAudienceConfig { + /** Name of the audience to create/test against. Used as the audienceName param for createAudience. */ + audienceName: string + /** Audience-level settings passed to createAudience and getAudience (e.g., id_type, owner_email). */ + audienceSettings: Record + /** When true, the runner calls createAudience before executing fixtures and captures the externalAudienceId. */ + createAudience: boolean + /** When true, the runner calls getAudience after fixtures to verify the audience still exists. */ + getAudience: boolean + /** When a function, the runner calls it after all tests to clean up the audience. Set to false to skip. */ + teardown: false | ((context: E2ETeardownAudienceContext) => Promise) +} + +export interface E2EAudienceDestinationConfig extends E2EDestinationConfig { + audience: E2EAudienceConfig +} + +export type E2EHttpSuccessCode = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 + +export type E2EHttpFailureCode = + | 300 + | 301 + | 302 + | 303 + | 304 + | 305 + | 306 + | 307 + | 308 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 409 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 421 + | 422 + | 423 + | 424 + | 425 + | 426 + | 428 + | 429 + | 431 + | 451 + | 499 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505 + | 506 + | 507 + | 508 + | 509 + | 510 + | 511 + | 529 + | 598 + | 599 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 26a3884eab..2694896e2a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,3 +112,42 @@ export { export { validateSchema } from './schema-validation' export { resolveAudienceMembership } from './audience-membership' export { FLAGS } from './flags' + +export { + createE2EEvent, + createE2EEngageAudienceEvent, + createE2EJourneysV1AudienceEvent, + createE2EJourneysV2AudienceEvent, + createE2ERetlAudienceEvent +} from './e2e-helpers' +export type { + E2EFixture, + E2EBaseFixture, + E2ESingleFixture, + E2EBatchFixture, + E2EBatchWithMultistatusFixture, + E2EExecutionMode, + E2EExpectation, + E2ESuccessExpectation, + E2EFailureExpectation, + E2EErrorExpectation, + E2EDestinationConfig, + E2EAudienceDestinationConfig, + E2EAudienceConfig, + E2ETeardownContext, + E2ETeardownAudienceContext, + E2ESettingsSecretValue, + E2EDynamicValue, + E2EEngageAudienceEventOptions, + E2EEngageAudienceEvent, + E2EEngageAudienceTrackEvent, + E2EEngageAudienceIdentifyEvent, + E2EJourneysV1AudienceEventOptions, + E2EJourneysV1AudienceTrackEvent, + E2EJourneysV2AudienceEventOptions, + E2EJourneysV2AudienceTrackEvent, + E2ERetlAudienceEventOptions, + E2ERetlAudienceTrackEvent, + E2EHttpSuccessCode, + E2EHttpFailureCode +} from './e2e-types'