[STRATCONN-6836] - E2E proof of concept#3817
Draft
joe-ayoub-segment wants to merge 58 commits into
Draft
Conversation
Introduce E2EFixture, E2EExpectation, and E2EDestinationConfig types in actions-core for strongly typed end-to-end destination testing. Add initial fixtures for Iterable trackEvent and updateUser actions as a proof of concept. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rename HttpSuccessCode → E2EHttpSuccessCode and HttpFailureCode → E2EHttpFailureCode to namespace them clearly as e2e testing types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR is a spike that introduces a lightweight end-to-end testing capability for Action Destinations. It adds shared E2E type definitions to @segment/actions-core and demonstrates the pattern with a set of fixtures for the Iterable destination (track event success, track event missing-identifiers failure, and update user success).
Changes:
- Adds
e2e-types.tstopackages/coredefiningE2EFixture,E2EExpectationvariants (success/failure/error),E2EDestinationConfig, and HTTP status code unions, and re-exports them from the package entry point. - Introduces an
__e2e__/index.tsconfig file for the Iterable destination that wires per-action fixtures and references anE2E_ITERABLE_API_KEYenv-backed setting. - Adds three Iterable fixtures (
trackEvent/success,trackEvent/missing-identifiers,updateUser/success) illustrating both successful execution and a client-sidePayloadValidationErrorcase.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| packages/core/src/e2e-types.ts | New shared types for E2E fixtures, expectations, destination config, and HTTP status code unions. |
| packages/core/src/index.ts | Re-exports the new E2E types from the core package entry point. |
| packages/destination-actions/src/destinations/iterable/e2e/index.ts | Iterable E2E config: settings (with env-backed API key) and per-action fixture map. |
| packages/destination-actions/src/destinations/iterable/trackEvent/e2e/success.ts | Fixture asserting a successful Iterable trackEvent for an Order Completed event. |
| packages/destination-actions/src/destinations/iterable/trackEvent/e2e/missing-identifiers.ts | Fixture asserting a PayloadValidationError when both email and userId are missing. |
| packages/destination-actions/src/destinations/iterable/updateUser/e2e/success.ts | Fixture asserting a successful Iterable updateUser identify call. |
Replace individual fixture files with a single fixtures.ts per action that exports an array of E2EFixture. Reduces boilerplate in the index. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Runner will discover fixtures via filesystem glob instead of explicit imports. Index now only exports destination config. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Introduce E2EDynamicValue type documenting runner-resolved markers. Update Iterable fixtures to use $now for timestamps, $guid for messageIds, and $guid:orderId for consistent order ID generation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Derive mappings from action field definitions instead of hand-writing them. Tests now stay in sync with field changes automatically. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Auto-populates messageId ($guid) and timestamp ($now). Fixture authors only provide type, event name, and test-specific overrides. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move createE2EEvent to e2e-helpers.ts, keeping e2e-types.ts as pure type definitions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
JSONObject doesn't accept undefined values. Use destructuring to exclude the keys entirely. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Generates fully-formed Engage audience event payloads for e2e testing. Handles context.personas structure, membership booleans, email placement, audienceFields, and enrichedTraits for both track and identify types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Return restricted E2EEngageAudienceEvent type instead of SegmentEvent - Add optional eventName param (defaults to 'Test Engage Audience Membership Event') - Remove email from track properties (stays in context.traits only) - Rename traits param to enrichedTraits - Flatten function to single const with spreading Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines
+108
to
+110
| export async function fetchSeedUserId(request: RequestClient, endpoint: string): Promise<string> { | ||
| const url = getEndpointByRegion('usersearch', endpoint) | ||
| const batches = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9']] |
Comment on lines
+110
to
+130
| const batches = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9']] | ||
|
|
||
| for (const batch of batches) { | ||
| const results = await Promise.all( | ||
| batch.map(async (prefix) => { | ||
| const response = await request<UserSearchResponse>(`${url}?user=${prefix}`) | ||
| const matches = response?.data?.matches || [] | ||
| for (const match of matches) { | ||
| if (match.user_id) { | ||
| return match.user_id | ||
| } | ||
| } | ||
| return undefined | ||
| }) | ||
| ) | ||
|
|
||
| const found = results.find((id) => id !== undefined) | ||
| if (found) { | ||
| return found | ||
| } | ||
| } |
| }, | ||
| { | ||
| description: 'Engage Audience: Remove a user from the customer match list via track event', | ||
| subscribe: 'event = "Audience Entered"', |
| * Only track events supported | ||
| */ | ||
| export function createE2EJourneysV1AudienceEvent<ComputationKey extends string>(options: E2EJourneysV1AudienceEventOptions<ComputationKey>): E2EJourneysV1AudienceTrackEvent<ComputationKey> { | ||
| const { computationKey, computationId, externalAudienceId, eventName, userId, anonymousId, email, audienceFields, enrichedTraits } = options |
Comment on lines
153
to
+155
| actions: { | ||
| syncAudience | ||
| }, | ||
| presets: [ | ||
| { | ||
| name: 'Entities Audience Membership Changed', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_audience_membership_changed_identify' | ||
| }, | ||
| { | ||
| name: 'Associated Entity Added', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_entity_added_track' | ||
| }, | ||
| { | ||
| name: 'Associated Entity Removed', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_entity_removed_track' | ||
| }, | ||
| { | ||
| name: 'Journeys Step Entered', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'journeys_step_entered_track' | ||
| } | ||
| ] | ||
| } |
… field - Extract buildAudienceEventBase() to eliminate duplicated context/personas construction across all three audience event helpers - Remove unused `action` field from E2EJourneysV1AudienceEventOptions and all fixture call sites Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines
153
to
+155
| actions: { | ||
| syncAudience | ||
| }, | ||
| presets: [ | ||
| { | ||
| name: 'Entities Audience Membership Changed', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_audience_membership_changed_identify' | ||
| }, | ||
| { | ||
| name: 'Associated Entity Added', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_entity_added_track' | ||
| }, | ||
| { | ||
| name: 'Associated Entity Removed', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_entity_removed_track' | ||
| }, | ||
| { | ||
| name: 'Journeys Step Entered', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'journeys_step_entered_track' | ||
| } | ||
| ] | ||
| } |
| @@ -1,4 +1,4 @@ | |||
| import { AudienceDestinationDefinition, defaultValues } from '@segment/actions-core' | |||
| import { AudienceDestinationDefinition } from '@segment/actions-core' | |||
Comment on lines
+82
to
+87
| const elapsed = Date.now() - startTime | ||
| if (elapsed < REMOVAL_AWAIT_THRESHOLD_MS) { | ||
| await removeSeedUser(request, id, endpoint, seedUserId, statsContext) | ||
| } else { | ||
| void removeSeedUser(request, id, endpoint, seedUserId, statsContext) | ||
| } |
- Add e2e config and 9 fixtures for pinterest-conversions - Fix createE2EEvent: use event for track, name for page/screen, throw for identify/group/alias when name is passed - Fix createE2EEngageAudienceEvent: throw if eventName passed with type=identify - Update iterable and amplitude identify fixtures accordingly - Fix legacy custom_data quantity label and opt_out_type description - Add presets for new event types, reference EVENT_NAME constants - Add Pinterest API payload types and wire return types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use PAYLOAD_VALIDATION_FAILED error code for choices validation - Pinterest returns 422 for stale timestamps, not 400 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines
+82
to
+87
| const elapsed = Date.now() - startTime | ||
| if (elapsed < REMOVAL_AWAIT_THRESHOLD_MS) { | ||
| await removeSeedUser(request, id, endpoint, seedUserId, statsContext) | ||
| } else { | ||
| void removeSeedUser(request, id, endpoint, seedUserId, statsContext) | ||
| } |
Comment on lines
153
to
+155
| actions: { | ||
| syncAudience | ||
| }, | ||
| presets: [ | ||
| { | ||
| name: 'Entities Audience Membership Changed', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_audience_membership_changed_identify' | ||
| }, | ||
| { | ||
| name: 'Associated Entity Added', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_entity_added_track' | ||
| }, | ||
| { | ||
| name: 'Associated Entity Removed', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_entity_removed_track' | ||
| }, | ||
| { | ||
| name: 'Journeys Step Entered', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'journeys_step_entered_track' | ||
| } | ||
| ] | ||
| } |
Comment on lines
153
to
+155
| actions: { | ||
| syncAudience | ||
| }, | ||
| presets: [ | ||
| { | ||
| name: 'Entities Audience Membership Changed', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_audience_membership_changed_identify' | ||
| }, | ||
| { | ||
| name: 'Associated Entity Added', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_entity_added_track' | ||
| }, | ||
| { | ||
| name: 'Associated Entity Removed', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_entity_removed_track' | ||
| }, | ||
| { | ||
| name: 'Journeys Step Entered', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'journeys_step_entered_track' | ||
| } | ||
| ] | ||
| } |
| const COMPUTATION_KEY = 'e2e_test_user_list' | ||
| const COMPUTATION_ID = 'aud_e2e_google_001' | ||
|
|
||
| const FAILURE_HINT = 'Ensure GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID, GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET, and ADWORDS_DEVELOPER_TOKEN env vars are set. The customerId must be a valid Google Ads account.' |
Comment on lines
+8
to
+9
| const FAILURE_HINT = | ||
| 'Ensure GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID, GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET, and ADWORDS_DEVELOPER_TOKEN env vars are set. The customerId must be a valid Google Ads account.' |
| const COMPUTATION_KEY = 'e2e_test_user_list' | ||
| const COMPUTATION_ID = 'aud_e2e_google_retl_001' | ||
|
|
||
| const FAILURE_HINT = 'Ensure GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID, GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET, and ADWORDS_DEVELOPER_TOKEN env vars are set. The customerId must be a valid Google Ads account.' |
Comment on lines
+55
to
+63
| const response = await fetch( | ||
| `https://googleads.googleapis.com/v21/customers/${customerId}/userLists:mutate`, | ||
| { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| 'developer-token': process.env.ADWORDS_DEVELOPER_TOKEN ?? '', | ||
| authorization: `Bearer ${access_token}` | ||
| }, |
Comment on lines
+16
to
+52
| export function createE2EEvent( | ||
| type: SegmentEvent['type'], | ||
| name?: string, | ||
| overrides?: Partial<Omit<SegmentEvent, 'type' | 'event' | 'name' | 'messageId' | 'timestamp'>> | ||
| ): 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 | ||
| } |
Comment on lines
152
to
+155
| }, | ||
| actions: { | ||
| syncAudience | ||
| }, | ||
| presets: [ | ||
| { | ||
| name: 'Entities Audience Membership Changed', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_audience_membership_changed_identify' | ||
| }, | ||
| { | ||
| name: 'Associated Entity Added', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_entity_added_track' | ||
| }, | ||
| { | ||
| name: 'Associated Entity Removed', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_entity_removed_track' | ||
| }, | ||
| { | ||
| name: 'Journeys Step Entered', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'journeys_step_entered_track' | ||
| } | ||
| ] | ||
| } |
Comment on lines
+82
to
+87
| const elapsed = Date.now() - startTime | ||
| if (elapsed < REMOVAL_AWAIT_THRESHOLD_MS) { | ||
| await removeSeedUser(request, id, endpoint, seedUserId, statsContext) | ||
| } else { | ||
| void removeSeedUser(request, id, endpoint, seedUserId, statsContext) | ||
| } |
Comment on lines
153
to
+155
| actions: { | ||
| syncAudience | ||
| }, | ||
| presets: [ | ||
| { | ||
| name: 'Entities Audience Membership Changed', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_audience_membership_changed_identify' | ||
| }, | ||
| { | ||
| name: 'Associated Entity Added', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_entity_added_track' | ||
| }, | ||
| { | ||
| name: 'Associated Entity Removed', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'warehouse_entity_removed_track' | ||
| }, | ||
| { | ||
| name: 'Journeys Step Entered', | ||
| partnerAction: 'syncAudience', | ||
| mapping: defaultValues(syncAudience.fields), | ||
| type: 'specificEvent', | ||
| eventSlug: 'journeys_step_entered_track' | ||
| } | ||
| ] | ||
| } |
Comment on lines
+55
to
+68
| const response = await fetch( | ||
| `https://googleads.googleapis.com/v21/customers/${customerId}/userLists:mutate`, | ||
| { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| 'developer-token': process.env.ADWORDS_DEVELOPER_TOKEN ?? '', | ||
| authorization: `Bearer ${access_token}` | ||
| }, | ||
| body: JSON.stringify({ | ||
| operations: [{ remove: `customers/${customerId}/userLists/${externalAudienceId}` }] | ||
| }) | ||
| } | ||
| ) |
| const COMPUTATION_KEY = 'e2e_test_user_list' | ||
| const COMPUTATION_ID = 'aud_e2e_google_001' | ||
|
|
||
| const FAILURE_HINT = 'Ensure GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID, GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET, and ADWORDS_DEVELOPER_TOKEN env vars are set. The customerId must be a valid Google Ads account.' |
Comment on lines
+8
to
+9
| const FAILURE_HINT = | ||
| 'Ensure GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID, GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET, and ADWORDS_DEVELOPER_TOKEN env vars are set. The customerId must be a valid Google Ads account.' |
| const COMPUTATION_KEY = 'e2e_test_user_list' | ||
| const COMPUTATION_ID = 'aud_e2e_google_retl_001' | ||
|
|
||
| const FAILURE_HINT = 'Ensure GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID, GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET, and ADWORDS_DEVELOPER_TOKEN env vars are set. The customerId must be a valid Google Ads account.' |
Comment on lines
+223
to
+234
| { | ||
| ...createE2EEngageAudienceEvent({ | ||
| type: 'identify', | ||
| action: 'remove', | ||
| computationKey: COMPUTATION_KEY, | ||
| computationId: COMPUTATION_ID, | ||
| externalAudienceId: '$externalAudienceId', | ||
| userId: 'e2e-fb-user-011', | ||
| email: 'e2e-fb-test-011@segment.com' | ||
| }), | ||
| userId: undefined as unknown as string | ||
| } |
Comment on lines
+18
to
+21
| properties: { | ||
| [COMPUTATION_KEY]: true, | ||
| email: options.email | ||
| }, |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Build a light weight end to end testing capability to exercise destination -> destination platform code.
Testing
Security Review
Please ensure sensitive data is properly protected in your integration.
type: 'password'New Destination Checklist
verioning-info.tsfile. example