Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/react-native-scene-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ on:
paths:
- '.github/workflows/react-native-scene-e2e.yml'
- '.maestro/fixtures/customerio_scene_cold.apns'
- '.maestro/fixtures/customerio_scene_declined.apns'
- '.maestro/fixtures/customerio_scene_warm.apns'
- '.maestro/fixtures/react-native-scene/**'
- '.maestro/run_scene_push.sh'
- '.maestro/scene_push_open.yaml'
- '.maestro/scene_push_declined.yaml'
- '.maestro/scene_push_prepare.yaml'
- '.maestro/scene_push_warm.yaml'
- '.maestro/scene_url_open.yaml'
- 'src/customerio-cdp.ts'
- 'src/customerio-push.ts'
- 'src/index.ts'
Expand All @@ -24,6 +27,7 @@ on:
- 'src/utils/native-bridge.ts'
- 'src/utils/param-validation.ts'
- 'ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift'
- 'ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift'
- 'ios/wrappers/NativeCustomerIO.swift'
- 'ios/wrappers/NativeCustomerIO.mm'
- 'ios/wrappers/liveactivities/NativeLiveActivities.swift'
Expand All @@ -33,6 +37,7 @@ on:
- 'ios/wrappers/CustomerioReactnative-Bridging-Header.h'
- 'ios/cocoapods_deployment_target.rb'
- 'customerio-reactnative.podspec'
- 'scripts/test_ios_deep_link_request_store.swift'
- 'package.json'
- 'package-lock.json'
schedule:
Expand Down Expand Up @@ -88,6 +93,15 @@ jobs:
unzip -q "$maestro_zip" -d "$maestro_dir"
echo "$maestro_dir/maestro/bin" >> "$GITHUB_PATH"

- name: Test deep-link acknowledgement state
shell: bash
run: |
xcrun swiftc \
ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift \
scripts/test_ios_deep_link_request_store.swift \
-o "$RUNNER_TEMP/test-ios-deep-link-request-store"
"$RUNNER_TEMP/test-ios-deep-link-request-store"

- name: Test warm and terminated notification routing
shell: bash
env:
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ jobs:
- name: Compile
run: npm run typescript

- name: Unit tests
run: npm test -- --runInBand --no-watchman

# `npm pack`, not `npm publish --dry-run`. From npm 11 (which node 24 bundles) a dry-run
# publish also runs the registry-side preflight, so it fails with "cannot publish over the
# previously published versions" on any branch whose version semantic-release has not bumped
Expand Down
16 changes: 16 additions & 0 deletions .maestro/fixtures/customerio_scene_declined.apns
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"aps": {
"alert": {
"title": "Customer.io scene routing",
"body": "Decline a React Native app-owned destination"
},
"sound": "default"
},
"CIO-Delivery-ID": "maestro-scene-route-declined",
"CIO-Delivery-Token": "maestro-scene-token-declined",
"CIO": {
"push": {
"link": "cio-rn-scene-e2e://declined"
}
}
}
89 changes: 67 additions & 22 deletions .maestro/fixtures/react-native-scene/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Linking, Text, View } from 'react-native';
import { Linking, Settings, Text, View } from 'react-native';
import {
CioPushPermissionStatus,
CioRegion,
Expand All @@ -25,46 +25,91 @@ async function validateLiveActivityBridge(): Promise<void> {
}

export default function App(): React.JSX.Element {
const routingMode =
Settings.get('CioSceneE2EPersistedMode') === 'linking'
? 'linking'
: 'acknowledged';
const [receivedUrl, setReceivedUrl] = useState<string | null>(null);
const [declineCount, setDeclineCount] = useState(0);
const [initialized, setInitialized] = useState(false);
const [failure, setFailure] = useState<string | null>(null);

useEffect(() => {
const subscription = Linking.addEventListener('url', ({ url }) => {
setReceivedUrl(url);
});
let active = true;
const subscriptions: Array<{ remove(): void }> = [];

CustomerIO.initialize({
cdpApiKey: 'scene-e2e-key',
region: CioRegion.US,
})
.then(() =>
CustomerIO.pushMessaging.showPromptForPushNotifications({
ios: { sound: true, badge: true },
const initialize = async () => {
subscriptions.push(
Linking.addEventListener('url', ({ url }) => {
setReceivedUrl(url);
})
)
.then(async (status) => {
if (status !== CioPushPermissionStatus.Granted) {
throw new Error(`Push permission is ${status}`);
);

const initialUrl = await Linking.getInitialURL();
if (initialUrl) {
setReceivedUrl(initialUrl);
}

if (routingMode === 'linking') {
CustomerIO.setDeepLinkRoutingReady();
}

await CustomerIO.initialize({
cdpApiKey: 'scene-e2e-key',
region: CioRegion.US,
});

// Register after initialization in acknowledged mode. This intentionally exercises the cold
// replay boundary where initialization must not drain the URL into legacy Linking.
if (routingMode === 'acknowledged') {
const handlerSubscription = CustomerIO.setDeepLinkHandler((url) => {
if (url === 'cio-rn-scene-e2e://declined') {
setDeclineCount((count) => count + 1);
return false;
}
setReceivedUrl(url);
return true;
});
if (!active) {
handlerSubscription.remove();
return;
}
await validateLiveActivityBridge();
setInitialized(true);
})
.catch((error: unknown) => {
subscriptions.push(handlerSubscription);
}

const status =
await CustomerIO.pushMessaging.showPromptForPushNotifications({
ios: { sound: true, badge: true },
});
if (status !== CioPushPermissionStatus.Granted) {
throw new Error(`Push permission is ${status}`);
}
await validateLiveActivityBridge();
setInitialized(true);
};

initialize().catch((error: unknown) => {
if (active) {
setFailure(error instanceof Error ? error.message : String(error));
});
}
});

return () => subscription.remove();
}, []);
return () => {
active = false;
subscriptions.forEach((subscription) => subscription.remove());
};
}, [routingMode]);

return (
<View>
<Text>
Customer.io React Native scene E2E{' '}
{initialized ? 'ready' : 'initializing'}
</Text>
<Text>Routing: {routingMode}</Text>
{failure && <Text>Initialization failed: {failure}</Text>}
{receivedUrl && <Text>Received: {receivedUrl}</Text>}
{declineCount > 0 && <Text>Declined count: {declineCount}</Text>}
</View>
);
}
20 changes: 19 additions & 1 deletion .maestro/fixtures/react-native-scene/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import UIKit
import customerio_reactnative

class SceneDelegate: RCTDefaultReactNativeFactoryDelegate, UIWindowSceneDelegate {
private static let launchModeKey = "CioSceneE2EMode"
private static let persistedModeKey = "CioSceneE2EPersistedMode"

var window: UIWindow?
var reactNativeFactory: RCTReactNativeFactory?

Expand All @@ -15,7 +18,11 @@ class SceneDelegate: RCTDefaultReactNativeFactoryDelegate, UIWindowSceneDelegate
) {
guard let windowScene = scene as? UIWindowScene else { return }

NativeCustomerIO.configureSceneDeepLinkRouting()
if usesAcknowledgedHandler {
NativeCustomerIO.configureAcknowledgedSceneDeepLinkRouting()
} else {
NativeCustomerIO.configureSceneDeepLinkRouting()
}
dependencyProvider = RCTAppDependencyProvider()
reactNativeFactory = RCTReactNativeFactory(delegate: self)
window = UIWindow(windowScene: windowScene)
Expand All @@ -41,4 +48,15 @@ class SceneDelegate: RCTDefaultReactNativeFactoryDelegate, UIWindowSceneDelegate
override func bundleURL() -> URL? {
Bundle.main.url(forResource: "main", withExtension: "jsbundle")
}

private var usesAcknowledgedHandler: Bool {
let arguments = ProcessInfo.processInfo.arguments
if arguments.contains(Self.launchModeKey) || arguments.contains("-\(Self.launchModeKey)"),
let mode = UserDefaults.standard.string(forKey: Self.launchModeKey)
{
UserDefaults.standard.set(mode, forKey: Self.persistedModeKey)
}

return UserDefaults.standard.string(forKey: Self.persistedModeKey) != "linking"
}
}
78 changes: 61 additions & 17 deletions .maestro/run_scene_push.sh
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ cleanup() {
else
echo "**Classification:** ${current_phase}-failed"
fi
echo '**Scope:** simulator notification presentation, tap, and exact React Native Linking destination; no backend delivery or attribution claim'
echo '**Scope:** simulator notification presentation, tap, ordinary warm and cold URLs, and exact acknowledged-handler and legacy-Linking destinations; no backend delivery or attribution claim'
} >> "$GITHUB_STEP_SUMMARY"
fi
return "$exit_code"
Expand Down Expand Up @@ -201,6 +201,30 @@ run_notification_flow() {
flow_log=""
}

run_ordinary_url_flow() {
local url="$1"
local state="$2"
local ordinary_args=(
--device "$device_id"
test
-e "EXPECTED_URL=$url"
.maestro/scene_url_open.yaml
)

xcrun simctl openurl "$device_id" "$url"
if [[ -n "${RUNNER_TEMP:-}" ]]; then
ordinary_args=(
--device "$device_id"
test
--debug-output "$RUNNER_TEMP/react-native-scene-maestro-scene_url_open-$state"
--flatten-debug-output
-e "EXPECTED_URL=$url"
.maestro/scene_url_open.yaml
)
fi
maestro "${ordinary_args[@]}"
}

cd "$REPO_ROOT"
current_phase="package"
npm ci
Expand Down Expand Up @@ -239,6 +263,10 @@ plist="ios/$APP_NAME/Info.plist"
/usr/libexec/PlistBuddy -c 'Add :UIApplicationSceneManifest:UISceneConfigurations:UIWindowSceneSessionRoleApplication:0:UISceneConfigurationName string Default Configuration' "$plist"
# shellcheck disable=SC2016
/usr/libexec/PlistBuddy -c 'Add :UIApplicationSceneManifest:UISceneConfigurations:UIWindowSceneSessionRoleApplication:0:UISceneDelegateClassName string $(PRODUCT_MODULE_NAME).SceneDelegate' "$plist"
/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes array' "$plist"
/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes:0 dict' "$plist"
/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes:0:CFBundleURLSchemes array' "$plist"
/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string cio-rn-scene-e2e' "$plist"

current_phase="pods"
bundle install
Expand All @@ -263,19 +291,35 @@ xcrun simctl install "$device_id" "$app_path"
installed_app=true

cd "$REPO_ROOT"
current_phase="prepare"
prepare_args=(--device "$device_id" test .maestro/scene_push_prepare.yaml)
if [[ -n "${RUNNER_TEMP:-}" ]]; then
prepare_args=(
--device "$device_id"
test
--debug-output "$RUNNER_TEMP/react-native-scene-maestro-scene_push_prepare"
--flatten-debug-output
.maestro/scene_push_prepare.yaml
)
fi
maestro "${prepare_args[@]}"
xcrun simctl terminate "$device_id" "$APP_ID"
current_phase="routing"
run_notification_flow .maestro/scene_push_open.yaml .maestro/fixtures/customerio_scene_cold.apns
run_notification_flow .maestro/scene_push_warm.yaml .maestro/fixtures/customerio_scene_warm.apns
for routing_mode in acknowledged linking; do
current_phase="prepare-$routing_mode"
prepare_args=(--device "$device_id" test -e "ROUTING_MODE=$routing_mode" .maestro/scene_push_prepare.yaml)
if [[ -n "${RUNNER_TEMP:-}" ]]; then
prepare_args=(
--device "$device_id"
test
--debug-output "$RUNNER_TEMP/react-native-scene-maestro-scene_push_prepare-$routing_mode"
--flatten-debug-output
-e "ROUTING_MODE=$routing_mode"
.maestro/scene_push_prepare.yaml
)
fi
maestro "${prepare_args[@]}"

if [[ "$routing_mode" == acknowledged ]]; then
current_phase="ordinary-link-warm-$routing_mode"
run_ordinary_url_flow 'cio-rn-scene-e2e://ordinary-warm' warm
xcrun simctl terminate "$device_id" "$APP_ID"

current_phase="ordinary-link-cold-$routing_mode"
run_ordinary_url_flow 'cio-rn-scene-e2e://ordinary-cold' cold
fi
xcrun simctl terminate "$device_id" "$APP_ID"

current_phase="routing-$routing_mode"
run_notification_flow .maestro/scene_push_open.yaml .maestro/fixtures/customerio_scene_cold.apns
run_notification_flow .maestro/scene_push_warm.yaml .maestro/fixtures/customerio_scene_warm.apns
if [[ "$routing_mode" == acknowledged ]]; then
run_notification_flow .maestro/scene_push_declined.yaml .maestro/fixtures/customerio_scene_declined.apns
fi
done
16 changes: 16 additions & 0 deletions .maestro/scene_push_declined.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
appId: org.reactjs.native.example.CioRnSceneHost
name: React Native UIScene declined app-owned URL
---
- pressKey: Home
- extendedWaitUntil:
visible:
id: 'NotificationShortLookView'
timeout: 60000
- tapOn:
id: 'NotificationShortLookView'
retryTapIfNoChange: false
- extendedWaitUntil:
visible: 'Declined count: 1'
timeout: 15000
- assertVisible: 'Received: cio-rn-scene-e2e://declined'
- assertNotVisible: 'Declined count: 2'
5 changes: 4 additions & 1 deletion .maestro/scene_push_prepare.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ name: React Native UIScene push routing, prepare
---
- launchApp:
clearState: false
arguments:
CioSceneE2EMode: ${ROUTING_MODE}
permissions:
notifications: allow
- extendedWaitUntil:
visible: "Customer.io React Native scene E2E ready"
visible: 'Customer.io React Native scene E2E ready'
timeout: 15000
- assertVisible: 'Routing: ${ROUTING_MODE}'
11 changes: 11 additions & 0 deletions .maestro/scene_url_open.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
appId: org.reactjs.native.example.CioRnSceneHost
name: React Native UIScene ordinary URL
---
- tapOn:
text: Open
optional: true
- extendedWaitUntil:
visible: 'Customer.io React Native scene E2E ready'
timeout: 15000
- assertVisible: 'Routing: acknowledged'
- assertVisible: 'Received: ${EXPECTED_URL}'
Loading
Loading