Skip to content

Commit 09f1236

Browse files
authored
Skip test cases list in maestro tests using launch arguments (#757)
## Summary - Pass `e2e_test_flow` as a Maestro `launchApp` argument so the app navigates directly to the target test case screen, bypassing the Test Cases list - Adds a local `LaunchArgsPlugin` Capacitor plugin on both iOS (reads UserDefaults) and Android (reads intent extras) to bridge the argument to the web layer - Makes maestro tests faster by skipping the list navigation step - The Test Cases list is preserved for manual/local usage ## Related PRs - RevenueCat/react-native-purchases#1722 - RevenueCat/purchases-kmp#796 - RevenueCat/purchases-flutter#1714 - RevenueCat/cordova-plugin-purchases#919 - RevenueCat/purchases-unity#897 Follows the same pattern as the iOS SDK's maestro app (`purchases-ios/Examples/rc-maestro`).
1 parent 27d7448 commit 09f1236

13 files changed

Lines changed: 250 additions & 86 deletions

File tree

e2e-tests/MaestroTestApp/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,42 @@ To run locally, either:
3939
- Replace the placeholder in `src/app.ts` with a valid API key (do **not** commit it), or
4040
- Export the env var and run the same `sed` command the Fastlane lane uses.
4141

42+
## Test Flow Launch Argument
43+
44+
To keep Maestro flows short and decoupled from the UI, the app reads an
45+
`e2e_test_flow` launch argument on startup and jumps straight to the matching
46+
screen. If the argument is absent or unknown, the app falls back to the
47+
"Test Cases" list.
48+
49+
Maestro passes it via [`launchApp.arguments`](https://maestro.mobile.dev/api-reference/commands/launchapp):
50+
51+
```yaml
52+
- launchApp:
53+
arguments:
54+
e2e_test_flow: purchase_through_paywall
55+
```
56+
57+
Each platform has a thin `LaunchArgs` Capacitor plugin that exposes the value
58+
to the web layer:
59+
60+
- **iOS** (`ios/App/App/LaunchArgsPlugin.swift`): reads the value from
61+
`UserDefaults.standard` using the `e2e_test_flow` key. Maestro's `arguments`
62+
map is forwarded to the process as `NSUserDefaults` entries.
63+
- **Android** (`android/app/src/main/java/com/revenuecat/automatedsdktests/LaunchArgsPlugin.java`):
64+
reads the value from the launch `Intent`'s string extras using the
65+
`e2e_test_flow` key. Maestro's `arguments` map is forwarded as Intent extras.
66+
67+
### Adding a new test flow
68+
69+
1. Create `src/screens/<your_test_case>.ts` exporting a `show*` function
70+
(see [`src/screens/purchase_through_paywall.ts`](src/screens/purchase_through_paywall.ts)
71+
as a template).
72+
2. Register it in [`src/test_cases.ts`](src/test_cases.ts), keyed by the flow
73+
value you plan to pass from Maestro, with the title shown in the Test Cases
74+
list and the screen function to invoke.
75+
3. In the Maestro YAML, set `launchApp.arguments.e2e_test_flow` to the same
76+
key.
77+
4278
## RevenueCat Project
4379

4480
The test uses a RevenueCat project configured with:
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.revenuecat.automatedsdktests;
2+
3+
import com.getcapacitor.JSObject;
4+
import com.getcapacitor.Plugin;
5+
import com.getcapacitor.PluginCall;
6+
import com.getcapacitor.PluginMethod;
7+
import com.getcapacitor.annotation.CapacitorPlugin;
8+
9+
@CapacitorPlugin(name = "LaunchArgs")
10+
public class LaunchArgsPlugin extends Plugin {
11+
12+
@PluginMethod
13+
public void getTestFlow(PluginCall call) {
14+
String testFlow = getActivity().getIntent().getStringExtra("e2e_test_flow");
15+
JSObject ret = new JSObject();
16+
ret.put("value", testFlow);
17+
call.resolve(ret);
18+
}
19+
}
Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
package com.revenuecat.automatedsdktests;
22

3+
import android.content.Intent;
4+
import android.os.Bundle;
35
import com.getcapacitor.BridgeActivity;
46

5-
public class MainActivity extends BridgeActivity {}
7+
public class MainActivity extends BridgeActivity {
8+
9+
@Override
10+
protected void onCreate(Bundle savedInstanceState) {
11+
registerPlugin(LaunchArgsPlugin.class);
12+
super.onCreate(savedInstanceState);
13+
}
14+
15+
// MainActivity is declared as singleTask, so subsequent launches arrive via onNewIntent
16+
// instead of recreating the activity. Update the stored intent so LaunchArgsPlugin reads
17+
// the latest extras (e.g. a new e2e_test_flow value) on relaunch.
18+
@Override
19+
protected void onNewIntent(Intent intent) {
20+
super.onNewIntent(intent);
21+
setIntent(intent);
22+
}
23+
}

e2e-tests/MaestroTestApp/ios/App/App.xcodeproj/project.pbxproj

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
1212
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
1313
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
14+
958DCC732DB07C7200EA8C60 /* LaunchArgsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 958DCC742DB07C7200EA8C61 /* LaunchArgsPlugin.swift */; };
15+
958DCC752DB07C7200EA8C62 /* MaestroTestBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 958DCC762DB07C7200EA8C63 /* MaestroTestBridgeViewController.swift */; };
1416
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
1517
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
1618
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
@@ -28,6 +30,8 @@
2830
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
2931
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
3032
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
33+
958DCC742DB07C7200EA8C61 /* LaunchArgsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchArgsPlugin.swift; sourceTree = "<group>"; };
34+
958DCC762DB07C7200EA8C63 /* MaestroTestBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaestroTestBridgeViewController.swift; sourceTree = "<group>"; };
3135
/* End PBXFileReference section */
3236

3337
/* Begin PBXFrameworksBuildPhase section */
@@ -63,8 +67,10 @@
6367
isa = PBXGroup;
6468
children = (
6569
50379B222058CBB4000EE86E /* capacitor.config.json */,
66-
504EC3071FED79650016851F /* AppDelegate.swift */,
67-
504EC30B1FED79650016851F /* Main.storyboard */,
70+
504EC3071FED79650016851F /* AppDelegate.swift */,
71+
958DCC742DB07C7200EA8C61 /* LaunchArgsPlugin.swift */,
72+
958DCC762DB07C7200EA8C63 /* MaestroTestBridgeViewController.swift */,
73+
504EC30B1FED79650016851F /* Main.storyboard */,
6874
504EC30E1FED79650016851F /* Assets.xcassets */,
6975
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
7076
504EC3131FED79650016851F /* Info.plist */,
@@ -156,6 +162,8 @@
156162
buildActionMask = 2147483647;
157163
files = (
158164
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
165+
958DCC732DB07C7200EA8C60 /* LaunchArgsPlugin.swift in Sources */,
166+
958DCC752DB07C7200EA8C62 /* MaestroTestBridgeViewController.swift in Sources */,
159167
);
160168
runOnlyForDeploymentPostprocessing = 0;
161169
};

e2e-tests/MaestroTestApp/ios/App/App/Base.lproj/Main.storyboard

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<!--Bridge View Controller-->
1212
<scene sceneID="tne-QT-ifu">
1313
<objects>
14-
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
14+
<viewController id="BYZ-38-t0r" customClass="MaestroTestBridgeViewController" customModule="App" sceneMemberID="viewController"/>
1515
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
1616
</objects>
1717
</scene>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Capacitor
2+
3+
@objc(LaunchArgsPlugin)
4+
public class LaunchArgsPlugin: CAPPlugin, CAPBridgedPlugin {
5+
public let identifier = "LaunchArgsPlugin"
6+
public let jsName = "LaunchArgs"
7+
public let pluginMethods: [CAPPluginMethod] = [
8+
CAPPluginMethod(name: "getTestFlow", returnType: CAPPluginReturnPromise)
9+
]
10+
11+
@objc func getTestFlow(_ call: CAPPluginCall) {
12+
let testFlow = UserDefaults.standard.string(forKey: "e2e_test_flow")
13+
call.resolve(["value": testFlow as Any])
14+
}
15+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import UIKit
2+
import Capacitor
3+
4+
class MaestroTestBridgeViewController: CAPBridgeViewController {
5+
override open func capacitorDidLoad() {
6+
bridge?.registerPluginInstance(LaunchArgsPlugin())
7+
}
8+
}
Lines changed: 22 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,37 @@
1-
import { Purchases, LOG_LEVEL } from '@revenuecat/purchases-capacitor';
2-
import { RevenueCatUI } from '@revenuecat/purchases-capacitor-ui';
1+
import { registerPlugin } from '@capacitor/core';
2+
import { LOG_LEVEL, Purchases } from '@revenuecat/purchases-capacitor';
33

4-
const API_KEY = 'MAESTRO_TESTS_REVENUECAT_API_KEY';
4+
import { showError } from './helpers';
5+
import { TEST_CASES } from './test_cases';
6+
import { showTestCases } from './test_cases_screen';
57

6-
let hasProEntitlement: boolean | null = null;
8+
const API_KEY = 'MAESTRO_TESTS_REVENUECAT_API_KEY';
79

8-
function entitlementsText(): string {
9-
if (hasProEntitlement === null) return 'Entitlements: loading';
10-
return `Entitlements: ${hasProEntitlement ? 'pro' : 'none'}`;
10+
interface LaunchArgsPlugin {
11+
getTestFlow(): Promise<{ value: string | null }>;
1112
}
1213

13-
function updateEntitlementsLabel() {
14-
const label = document.getElementById('entitlements-label');
15-
if (label) label.textContent = entitlementsText();
16-
}
17-
18-
function showError(message: string) {
19-
let el = document.getElementById('error-message');
20-
if (!el) {
21-
el = document.createElement('p');
22-
el.id = 'error-message';
23-
el.style.color = 'red';
24-
el.style.fontSize = '14px';
25-
document.getElementById('app')?.appendChild(el);
26-
}
27-
el.textContent = `Error: ${message}`;
28-
}
29-
30-
function clearError() {
31-
const el = document.getElementById('error-message');
32-
if (el) el.remove();
33-
}
14+
const LaunchArgs = registerPlugin<LaunchArgsPlugin>('LaunchArgs');
3415

3516
async function init() {
3617
try {
3718
await Purchases.setLogLevel({ level: LOG_LEVEL.DEBUG });
3819
await Purchases.configure({ apiKey: API_KEY });
3920

40-
await Purchases.addCustomerInfoUpdateListener((info) => {
41-
hasProEntitlement = info.entitlements.active['pro'] !== undefined;
42-
updateEntitlementsLabel();
43-
});
21+
let testFlow: string | null = null;
22+
try {
23+
const result = await LaunchArgs.getTestFlow();
24+
testFlow = result.value;
25+
} catch (_) {
26+
/* launch args not available */
27+
}
4428

45-
showTestCases();
29+
const match = testFlow ? TEST_CASES[testFlow] : null;
30+
if (match) {
31+
match.show();
32+
} else {
33+
showTestCases();
34+
}
4635
} catch (error) {
4736
const message = error instanceof Error ? error.message : String(error);
4837
console.error('Failed to initialize:', message);
@@ -51,45 +40,4 @@ async function init() {
5140
}
5241
}
5342

54-
function showTestCases() {
55-
document.getElementById('app')!.innerHTML = `
56-
<h1>Test Cases</h1>
57-
<button id="purchase-through-paywall-btn">Purchase through paywall</button>
58-
`;
59-
document.getElementById('purchase-through-paywall-btn')!.addEventListener('click', showPurchaseThroughPaywallScreen);
60-
}
61-
62-
async function showPurchaseThroughPaywallScreen() {
63-
document.getElementById('app')!.innerHTML = `
64-
<div class="center">
65-
<p id="entitlements-label">${entitlementsText()}</p>
66-
<button id="paywall-btn">Present Paywall</button>
67-
<button id="back-btn" style="background-color: #888; margin-top: 16px;">Back</button>
68-
</div>
69-
`;
70-
71-
try {
72-
const { customerInfo } = await Purchases.getCustomerInfo();
73-
hasProEntitlement = customerInfo.entitlements.active['pro'] !== undefined;
74-
updateEntitlementsLabel();
75-
} catch (error) {
76-
const message = error instanceof Error ? error.message : String(error);
77-
console.error('Failed to get customer info:', message);
78-
showError(message);
79-
}
80-
81-
document.getElementById('paywall-btn')!.addEventListener('click', async () => {
82-
clearError();
83-
try {
84-
await RevenueCatUI.presentPaywall();
85-
} catch (error) {
86-
const message = error instanceof Error ? error.message : String(error);
87-
console.error('Failed to present paywall:', message);
88-
showError(message);
89-
}
90-
});
91-
92-
document.getElementById('back-btn')!.addEventListener('click', showTestCases);
93-
}
94-
9543
init();
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export function showError(message: string): void {
2+
let el = document.getElementById('error-message');
3+
if (!el) {
4+
el = document.createElement('p');
5+
el.id = 'error-message';
6+
el.style.color = 'red';
7+
el.style.fontSize = '14px';
8+
document.getElementById('app')?.appendChild(el);
9+
}
10+
el.textContent = `Error: ${message}`;
11+
}
12+
13+
export function clearError(): void {
14+
const el = document.getElementById('error-message');
15+
if (el) el.remove();
16+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Purchases, type CustomerInfo } from '@revenuecat/purchases-capacitor';
2+
import { RevenueCatUI } from '@revenuecat/purchases-capacitor-ui';
3+
4+
import { clearError, showError } from '../helpers';
5+
import { showTestCases } from '../test_cases_screen';
6+
7+
export async function showPurchaseThroughPaywallScreen(): Promise<void> {
8+
let hasProEntitlement: boolean | null = null;
9+
let listenerId: string | null = null;
10+
11+
function entitlementsText(): string {
12+
if (hasProEntitlement === null) return 'Entitlements: loading';
13+
return `Entitlements: ${hasProEntitlement ? 'pro' : 'none'}`;
14+
}
15+
16+
function updateEntitlementsLabel() {
17+
const label = document.getElementById('entitlements-label');
18+
if (label) label.textContent = entitlementsText();
19+
}
20+
21+
function applyCustomerInfo(info: CustomerInfo) {
22+
hasProEntitlement = info.entitlements.active['pro'] !== undefined;
23+
updateEntitlementsLabel();
24+
}
25+
26+
async function cleanup() {
27+
if (listenerId) {
28+
try {
29+
await Purchases.removeCustomerInfoUpdateListener({ listenerToRemove: listenerId });
30+
} catch (error) {
31+
console.warn('Failed to remove customer info listener:', error);
32+
}
33+
listenerId = null;
34+
}
35+
}
36+
37+
document.getElementById('app')!.innerHTML = `
38+
<div class="center">
39+
<p id="entitlements-label">${entitlementsText()}</p>
40+
<button id="paywall-btn">Present Paywall</button>
41+
<button id="back-btn" style="background-color: #888; margin-top: 16px;">Back</button>
42+
</div>
43+
`;
44+
45+
document.getElementById('paywall-btn')!.addEventListener('click', async () => {
46+
clearError();
47+
try {
48+
await RevenueCatUI.presentPaywall();
49+
} catch (error) {
50+
const message = error instanceof Error ? error.message : String(error);
51+
console.error('Failed to present paywall:', message);
52+
showError(message);
53+
}
54+
});
55+
56+
document.getElementById('back-btn')!.addEventListener('click', async () => {
57+
await cleanup();
58+
showTestCases();
59+
});
60+
61+
try {
62+
listenerId = await Purchases.addCustomerInfoUpdateListener(applyCustomerInfo);
63+
const { customerInfo } = await Purchases.getCustomerInfo();
64+
applyCustomerInfo(customerInfo);
65+
} catch (error) {
66+
const message = error instanceof Error ? error.message : String(error);
67+
console.error('Failed to get customer info:', message);
68+
showError(message);
69+
}
70+
}

0 commit comments

Comments
 (0)