@purchasely/cordova-plugin-purchasely 6.0.0-rc.3 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -14
- package/__tests__/Purchasely.test.js +820 -155
- package/example/IOS_8_1_1_TEST_MATRIX.md +25 -0
- package/example/e2e/README.md +2 -2
- package/example/e2e/helpers/driver.js +135 -18
- package/example/e2e/specs/bridge.e2e.js +116 -13
- package/example/e2e/specs/dismiss.e2e.js +32 -12
- package/example/e2e/tools/ci_run_e2e.sh +15 -1
- package/example/e2e/tools/ci_run_e2e_ios.sh +12 -2
- package/example/e2e/wdio.android.conf.js +3 -0
- package/example/e2e/wdio.ios.conf.js +26 -1
- package/example/ios.sh +6 -5
- package/example/package-lock.json +3 -3
- package/example/package.json +2 -2
- package/example/www/js/index.js +62 -58
- package/package.json +1 -1
- package/plugin.xml +3 -3
- package/src/android/PurchaselyPlugin.kt +247 -67
- package/src/ios/CDVPurchasely+Events.m +7 -1
- package/src/ios/CDVPurchasely.h +11 -0
- package/src/ios/CDVPurchasely.m +158 -6
- package/src/ios/Hybrid/PLYPlan+Hybrid.m +19 -0
- package/src/ios/Hybrid/PLYSubscription+Hybrid.m +13 -0
- package/www/Purchasely.js +390 -46
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# cordova-ios 8.1.1 Manual Test Matrix
|
|
2
|
+
|
|
3
|
+
Run this matrix on a physical iOS device or a StoreKit-configured simulator after:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
cd purchasely/example
|
|
7
|
+
./ios.sh
|
|
8
|
+
cordova run ios --device
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Use a Purchasely project with a presentation, a purchasable product, and a restorable
|
|
12
|
+
purchase. Capture the app logs and the callback payloads for every row.
|
|
13
|
+
|
|
14
|
+
| Scenario | Steps | Expected result |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| Presentation | Trigger the sample presentation. | The configured presentation is displayed using the requested transition. |
|
|
17
|
+
| Purchase | Select a product and complete the StoreKit purchase. | The StoreKit sheet completes, the purchase callback receives the Purchasely outcome, and the active entitlement is updated. |
|
|
18
|
+
| Restore | Use the sample restore action with a previously purchased account. | The restore callback completes and the restored entitlement is available. |
|
|
19
|
+
| Close | Dismiss the presentation through its close control and, separately, through the system back/dismiss gesture when enabled. | The close callback fires once per dismissal with the matching close reason; the final presentation outcome is delivered. |
|
|
20
|
+
| JavaScript callbacks | Repeat presentation, purchase, restore, and close while logging every success, failure, presented, and close-requested callback. | Each callback is received on the JavaScript bridge with its expected payload and no duplicate terminal callback. |
|
|
21
|
+
| Background and foreground | Show a presentation or StoreKit sheet, send the app to the background, then return it to the foreground. | The app remains responsive, the visible flow resumes or completes according to StoreKit, and no callback is lost or duplicated. |
|
|
22
|
+
|
|
23
|
+
This matrix is runtime validation only. The CI job verifies deterministic installation,
|
|
24
|
+
CocoaPods workspace build, and an unsigned archive; it cannot validate StoreKit or
|
|
25
|
+
backend-configured presentation behavior.
|
package/example/e2e/README.md
CHANGED
|
@@ -16,8 +16,8 @@ Cordova imperative API. They are **not** part of the PR-gating `ci.yml`; they ru
|
|
|
16
16
|
|
|
17
17
|
| Suite | File | Gate | Notes |
|
|
18
18
|
|-------|------|------|-------|
|
|
19
|
-
| bridge | `specs/bridge.e2e.js` | **hard** | anonymous id, allProducts, fetchPresentationForPlacement, synchronize completion, user-attribute round-trip |
|
|
20
|
-
| dismiss | `specs/dismiss.e2e.js` | best-effort | present placement + programmatic close → dismiss outcome + `closeReason` (needs a paywall to render) |
|
|
19
|
+
| bridge | `specs/bridge.e2e.js` | **hard** | anonymous id, allProducts, fetchPresentationForPlacement, synchronize completion, user-attribute round-trip (string/int/boolean), userSubscriptions |
|
|
20
|
+
| dismiss | `specs/dismiss.e2e.js` | best-effort | present placement or default presentation + programmatic close → dismiss outcome + `closeReason` (needs a paywall to render) |
|
|
21
21
|
|
|
22
22
|
Best-effort suites emit `::warning::` on failure and do not fail the job (native/paywall
|
|
23
23
|
rendering is flaky in CI — same policy as the Flutter suite). Each suite retries up to 3×.
|
|
@@ -32,24 +32,40 @@ async function waitForPurchaselyReady() {
|
|
|
32
32
|
);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Poll a window global until a native callback has populated it, then return it.
|
|
36
|
+
// Resolves { ok:false, error:'timeout' } if nothing arrives in time. We poll a SYNC
|
|
37
|
+
// execute rather than use executeAsync because the iOS WKWebView aborts async-script
|
|
38
|
+
// results almost immediately ("Timed out waiting for asynchronous script result"),
|
|
39
|
+
// which would fail every bridge call on iOS.
|
|
40
|
+
async function pollGlobal(name, timeoutMs) {
|
|
41
|
+
let value;
|
|
42
|
+
try {
|
|
43
|
+
await browser.waitUntil(
|
|
44
|
+
async () => {
|
|
45
|
+
value = await browser.execute(function (n) { return window[n]; }, name);
|
|
46
|
+
return value !== undefined && value !== null;
|
|
47
|
+
},
|
|
48
|
+
{ timeout: timeoutMs, interval: 250, timeoutMsg: name + ' never settled' }
|
|
49
|
+
);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return { ok: false, error: 'timeout' };
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
35
56
|
// Invoke a Purchasely method that takes trailing (success, error) callbacks and resolve
|
|
36
57
|
// with { ok, value } on success or { ok:false, error } on error. `args` are the leading
|
|
37
|
-
// positional arguments before the callbacks.
|
|
58
|
+
// positional arguments before the callbacks. Fires via a sync execute that stashes the
|
|
59
|
+
// settled result on window.__plyResult, then polls for it (see pollGlobal).
|
|
38
60
|
async function callBridge(method, args = [], timeoutMs = 30000) {
|
|
39
|
-
|
|
40
|
-
function (method, args
|
|
61
|
+
await browser.execute(
|
|
62
|
+
function (method, args) {
|
|
63
|
+
window.__plyResult = undefined;
|
|
41
64
|
var settled = false;
|
|
42
|
-
var finish = function (payload) {
|
|
43
|
-
if (settled) return;
|
|
44
|
-
settled = true;
|
|
45
|
-
done(payload);
|
|
46
|
-
};
|
|
47
|
-
setTimeout(function () { finish({ ok: false, error: 'timeout' }); }, timeoutMs);
|
|
65
|
+
var finish = function (payload) { if (!settled) { settled = true; window.__plyResult = payload; } };
|
|
48
66
|
try {
|
|
49
67
|
var fn = window.Purchasely[method];
|
|
50
|
-
if (typeof fn !== 'function') {
|
|
51
|
-
return finish({ ok: false, error: 'no such method: ' + method });
|
|
52
|
-
}
|
|
68
|
+
if (typeof fn !== 'function') { finish({ ok: false, error: 'no such method: ' + method }); return; }
|
|
53
69
|
fn.apply(
|
|
54
70
|
window.Purchasely,
|
|
55
71
|
args.concat([
|
|
@@ -57,14 +73,115 @@ async function callBridge(method, args = [], timeoutMs = 30000) {
|
|
|
57
73
|
function (error) { finish({ ok: false, error: String(error) }); },
|
|
58
74
|
])
|
|
59
75
|
);
|
|
60
|
-
} catch (e) {
|
|
61
|
-
finish({ ok: false, error: String(e) });
|
|
62
|
-
}
|
|
76
|
+
} catch (e) { finish({ ok: false, error: String(e) }); }
|
|
63
77
|
},
|
|
64
78
|
method,
|
|
65
|
-
args
|
|
66
|
-
|
|
79
|
+
args
|
|
80
|
+
);
|
|
81
|
+
return pollGlobal('__plyResult', timeoutMs);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Fire a void Purchasely method that takes NO success callback — the
|
|
85
|
+
// setUserAttributeWith* setters are fire-and-forget on the Cordova bridge
|
|
86
|
+
// (exec(() => {}, defaultError, ...)). callBridge would hang waiting for a success
|
|
87
|
+
// callback these never invoke, so dispatch the call and resolve immediately.
|
|
88
|
+
async function fireBridge(method, args = []) {
|
|
89
|
+
await browser.execute(
|
|
90
|
+
function (method, args) {
|
|
91
|
+
var fn = window.Purchasely[method];
|
|
92
|
+
if (typeof fn === 'function') fn.apply(window.Purchasely, args);
|
|
93
|
+
},
|
|
94
|
+
method,
|
|
95
|
+
args
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Drive the v6 presentation builder (Purchasely.presentation) from WEBVIEW context and
|
|
100
|
+
// resolve with { ok, value } / { ok:false, error } -- same contract as callBridge, since
|
|
101
|
+
// fetchPresentation*/presentPresentation* (and their (args..., success, error) shape that
|
|
102
|
+
// callBridge drives) were removed in favor of the promise-based builder.
|
|
103
|
+
// `source` is 'placement' | 'screen' | 'defaultSource'; `sourceId` is the placement/screen
|
|
104
|
+
// id (omit for 'defaultSource'); `action` is 'preload' or 'display'; `transition` is passed
|
|
105
|
+
// to display() when action is 'display'. Stashes the built request on
|
|
106
|
+
// `window.__plyLastRequest` so a follow-up closeCurrentPresentation()/backCurrentPresentation()
|
|
107
|
+
// call in the same test can drive it (mirrors closePresentation()/backPresentation() acting
|
|
108
|
+
// on the natively-tracked current presentation pre-builder).
|
|
109
|
+
async function callPresentation(source, sourceId, action, transition, timeoutMs = 90000) {
|
|
110
|
+
await browser.execute(
|
|
111
|
+
function (source, sourceId, action, transition) {
|
|
112
|
+
window.__plyResult = undefined;
|
|
113
|
+
var settled = false;
|
|
114
|
+
var finish = function (payload) { if (!settled) { settled = true; window.__plyResult = payload; } };
|
|
115
|
+
try {
|
|
116
|
+
var builder = sourceId
|
|
117
|
+
? window.Purchasely.presentation[source](sourceId)
|
|
118
|
+
: window.Purchasely.presentation[source]();
|
|
119
|
+
var request = builder.build();
|
|
120
|
+
window.__plyLastRequest = request;
|
|
121
|
+
var promise = action === 'preload' ? request.preload() : request.display(transition);
|
|
122
|
+
promise.then(
|
|
123
|
+
function (value) { finish({ ok: true, value: value }); },
|
|
124
|
+
function (error) { finish({ ok: false, error: String(error) }); }
|
|
125
|
+
);
|
|
126
|
+
} catch (e) { finish({ ok: false, error: String(e) }); }
|
|
127
|
+
},
|
|
128
|
+
source,
|
|
129
|
+
sourceId,
|
|
130
|
+
action,
|
|
131
|
+
transition
|
|
67
132
|
);
|
|
133
|
+
return pollGlobal('__plyResult', timeoutMs);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Display a presentation and stash its dismiss outcome on a window global, WITHOUT
|
|
137
|
+
// blocking the WebDriver session. callPresentation() drives display() inside a single
|
|
138
|
+
// executeAsync that only settles at dismiss — but the session is then busy, so the
|
|
139
|
+
// follow-up close() command can never run (deadlock -> script timeout). Fire display()
|
|
140
|
+
// fire-and-forget instead and poll the outcome via awaitDismissOutcome(); the session
|
|
141
|
+
// stays free to send closeCurrentPresentation() in between.
|
|
142
|
+
async function displayPresentation(source, sourceId, transition) {
|
|
143
|
+
await browser.execute(
|
|
144
|
+
function (source, sourceId, transition) {
|
|
145
|
+
window.__plyOutcome = undefined;
|
|
146
|
+
var builder = sourceId
|
|
147
|
+
? window.Purchasely.presentation[source](sourceId)
|
|
148
|
+
: window.Purchasely.presentation[source]();
|
|
149
|
+
var request = builder.build();
|
|
150
|
+
window.__plyLastRequest = request;
|
|
151
|
+
request.display(transition).then(
|
|
152
|
+
function (v) { window.__plyOutcome = { ok: true, value: v }; },
|
|
153
|
+
function (e) { window.__plyOutcome = { ok: false, error: String(e) }; }
|
|
154
|
+
);
|
|
155
|
+
},
|
|
156
|
+
source,
|
|
157
|
+
sourceId,
|
|
158
|
+
transition
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Poll for the dismiss outcome stashed by displayPresentation(). Resolves { ok, value } /
|
|
163
|
+
// { ok:false, error } once display()'s promise settles (i.e. after close()), or
|
|
164
|
+
// { ok:false, error:'timeout' } if no outcome arrives in time.
|
|
165
|
+
async function awaitDismissOutcome(timeoutMs = 30000) {
|
|
166
|
+
return pollGlobal('__plyOutcome', timeoutMs);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Close the presentation driven by the last callPresentation()/displayPresentation()
|
|
170
|
+
// request (WEBVIEW context).
|
|
171
|
+
async function closeCurrentPresentation() {
|
|
172
|
+
return browser.execute(function () {
|
|
173
|
+
if (window.__plyLastRequest) window.__plyLastRequest.close();
|
|
174
|
+
});
|
|
68
175
|
}
|
|
69
176
|
|
|
70
|
-
module.exports = {
|
|
177
|
+
module.exports = {
|
|
178
|
+
switchToWebview,
|
|
179
|
+
switchToNative,
|
|
180
|
+
waitForPurchaselyReady,
|
|
181
|
+
callBridge,
|
|
182
|
+
fireBridge,
|
|
183
|
+
callPresentation,
|
|
184
|
+
displayPresentation,
|
|
185
|
+
awaitDismissOutcome,
|
|
186
|
+
closeCurrentPresentation,
|
|
187
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Deterministic Dart<->native bridge assertions (no native taps). HARD gate — these
|
|
2
2
|
// must pass. Mirrors the Flutter E2E_TEST_INDEX suite T1/T3/T5/T6 adapted to the
|
|
3
3
|
// Cordova imperative API.
|
|
4
|
-
const { waitForPurchaselyReady, callBridge } = require('../helpers/driver');
|
|
4
|
+
const { waitForPurchaselyReady, callBridge, fireBridge, callPresentation } = require('../helpers/driver');
|
|
5
5
|
|
|
6
6
|
const PLACEMENT = process.env.PURCHASELY_E2E_PLACEMENT || 'ONBOARDING';
|
|
7
7
|
|
|
@@ -18,20 +18,33 @@ describe('Purchasely bridge (WEBVIEW context)', () => {
|
|
|
18
18
|
expect(res.value.length).toBeGreaterThan(0);
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
// T5 — catalog
|
|
21
|
+
// T5 — catalog. Store-dependent: the iOS simulator has no StoreKit products configured,
|
|
22
|
+
// so allProducts settles as a clean error there while the Android emulator (Google
|
|
23
|
+
// Billing) returns a list. Assert the bridge round-trips cleanly either way.
|
|
22
24
|
it('allProducts returns a list', async () => {
|
|
23
25
|
const res = await callBridge('allProducts');
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
if (res.ok) {
|
|
27
|
+
expect(Array.isArray(res.value)).toBe(true);
|
|
28
|
+
} else {
|
|
29
|
+
expect(typeof res.error).toBe('string');
|
|
30
|
+
}
|
|
26
31
|
});
|
|
27
32
|
|
|
28
|
-
// T3 — preload a presentation for a placement
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
|
|
33
|
+
// T3 — preload a presentation for a placement (was fetchPresentationForPlacement;
|
|
34
|
+
// now Purchasely.presentation.placement(id).build().preload())
|
|
35
|
+
it('presentation.placement(...).build().preload() returns a presentation object', async () => {
|
|
36
|
+
const res = await callPresentation('placement', PLACEMENT, 'preload');
|
|
37
|
+
// Store-dependent (preload fetches the paywall + its products): tolerate a clean error
|
|
38
|
+
// on the store-less iOS simulator, assert the shape when it resolves.
|
|
39
|
+
if (res.ok) {
|
|
40
|
+
// v6 presentation normalizes screenId as the authoritative identifier.
|
|
41
|
+
expect(res.value === null || typeof res.value === 'object').toBe(true);
|
|
42
|
+
if (res.value) {
|
|
43
|
+
expect(typeof res.value.screenId).toBe('string');
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
expect(typeof res.error).toBe('string');
|
|
47
|
+
}
|
|
35
48
|
});
|
|
36
49
|
|
|
37
50
|
// T6 — synchronize now reports completion (v6 change). On a bare emulator/simulator
|
|
@@ -41,11 +54,101 @@ describe('Purchasely bridge (WEBVIEW context)', () => {
|
|
|
41
54
|
expect(typeof res.ok).toBe('boolean');
|
|
42
55
|
});
|
|
43
56
|
|
|
44
|
-
// user-attribute round-trip (set then read back)
|
|
57
|
+
// user-attribute round-trip (set then read back). The setters are fire-and-forget on
|
|
58
|
+
// the Cordova bridge (no success callback), so fire them via fireBridge and read back
|
|
59
|
+
// with callBridge; the small pause lets the native set land before the read.
|
|
45
60
|
it('setUserAttributeWithString then userAttribute round-trips', async () => {
|
|
46
|
-
await
|
|
61
|
+
await fireBridge('setUserAttributeWithString', ['e2e_key', 'e2e_value', 'ESSENTIAL']);
|
|
62
|
+
await browser.pause(300);
|
|
47
63
|
const res = await callBridge('userAttribute', ['e2e_key']);
|
|
48
64
|
expect(res.ok).toBe(true);
|
|
49
65
|
expect(res.value).toBe('e2e_value');
|
|
50
66
|
});
|
|
67
|
+
|
|
68
|
+
// user-attribute round-trip, int variant
|
|
69
|
+
it('setUserAttributeWithInt then userAttribute round-trips', async () => {
|
|
70
|
+
await fireBridge('setUserAttributeWithInt', ['e2e_key_int', 7, 'ESSENTIAL']);
|
|
71
|
+
await browser.pause(300);
|
|
72
|
+
const res = await callBridge('userAttribute', ['e2e_key_int']);
|
|
73
|
+
expect(res.ok).toBe(true);
|
|
74
|
+
expect(res.value).toBe(7);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// user-attribute round-trip, boolean variant (CDV-W-09 fixed: both platforms now
|
|
78
|
+
// return a real JSON boolean, so this asserts strict equality, not just truthiness).
|
|
79
|
+
it('setUserAttributeWithBoolean then userAttribute round-trips', async () => {
|
|
80
|
+
await fireBridge('setUserAttributeWithBoolean', ['e2e_key_bool', true, 'ESSENTIAL']);
|
|
81
|
+
await browser.pause(300);
|
|
82
|
+
const res = await callBridge('userAttribute', ['e2e_key_bool']);
|
|
83
|
+
expect(res.ok).toBe(true);
|
|
84
|
+
expect(res.value).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// userSubscriptions on a fresh anonymous user. Store-dependent: settles as a list on the
|
|
88
|
+
// Android emulator, can settle as a clean error on the store-less iOS simulator.
|
|
89
|
+
it('userSubscriptions returns a list', async () => {
|
|
90
|
+
const res = await callBridge('userSubscriptions');
|
|
91
|
+
if (res.ok) {
|
|
92
|
+
expect(Array.isArray(res.value)).toBe(true);
|
|
93
|
+
} else {
|
|
94
|
+
expect(typeof res.error).toBe('string');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// T2 — login/logout cycle: isAnonymous flips true -> false -> true. userLogin/userLogout
|
|
99
|
+
// are fire-and-forget on the Cordova bridge, so drive them via fireBridge + verify via
|
|
100
|
+
// the callback-based isAnonymous getter.
|
|
101
|
+
it('isAnonymous flips around userLogin / userLogout', async () => {
|
|
102
|
+
let res = await callBridge('isAnonymous');
|
|
103
|
+
expect(res.ok).toBe(true);
|
|
104
|
+
expect(res.value).toBe(true);
|
|
105
|
+
|
|
106
|
+
await fireBridge('userLogin', ['cordova_e2e_user']);
|
|
107
|
+
await browser.pause(500);
|
|
108
|
+
res = await callBridge('isAnonymous');
|
|
109
|
+
expect(res.value).toBe(false);
|
|
110
|
+
|
|
111
|
+
await fireBridge('userLogout', []);
|
|
112
|
+
await browser.pause(500);
|
|
113
|
+
res = await callBridge('isAnonymous');
|
|
114
|
+
expect(res.value).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// T4 — dynamic offerings list (may be empty on a bare emulator)
|
|
118
|
+
it('getDynamicOfferings returns a list', async () => {
|
|
119
|
+
const res = await callBridge('getDynamicOfferings');
|
|
120
|
+
expect(res.ok).toBe(true);
|
|
121
|
+
expect(Array.isArray(res.value)).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// T18 — dynamic offerings set / get / remove / clear round-trip (list stays an array,
|
|
125
|
+
// no throw). The mutators are fire-and-forget; getDynamicOfferings reads back.
|
|
126
|
+
it('dynamic offerings set / remove / clear round-trip', async () => {
|
|
127
|
+
await fireBridge('setDynamicOffering', ['e2e_ref', 'e2e_plan', null, 0]);
|
|
128
|
+
await browser.pause(300);
|
|
129
|
+
let res = await callBridge('getDynamicOfferings');
|
|
130
|
+
expect(Array.isArray(res.value)).toBe(true);
|
|
131
|
+
|
|
132
|
+
await fireBridge('removeDynamicOffering', ['e2e_ref']);
|
|
133
|
+
await fireBridge('clearDynamicOfferings', []);
|
|
134
|
+
await browser.pause(300);
|
|
135
|
+
res = await callBridge('getDynamicOfferings');
|
|
136
|
+
expect(res.ok).toBe(true);
|
|
137
|
+
expect(Array.isArray(res.value)).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// T16 — increment / decrement a numeric user attribute
|
|
141
|
+
it('increment / decrement a numeric user attribute', async () => {
|
|
142
|
+
await fireBridge('setUserAttributeWithInt', ['e2e_counter', 5, 'ESSENTIAL']);
|
|
143
|
+
await browser.pause(300);
|
|
144
|
+
await fireBridge('incrementUserAttribute', ['e2e_counter', 3]);
|
|
145
|
+
await browser.pause(300);
|
|
146
|
+
let res = await callBridge('userAttribute', ['e2e_counter']);
|
|
147
|
+
expect(res.value).toBe(8);
|
|
148
|
+
|
|
149
|
+
await fireBridge('decrementUserAttribute', ['e2e_counter', 2]);
|
|
150
|
+
await browser.pause(300);
|
|
151
|
+
res = await callBridge('userAttribute', ['e2e_counter']);
|
|
152
|
+
expect(res.value).toBe(6);
|
|
153
|
+
});
|
|
51
154
|
});
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// Presentation display + dismiss outcome. BEST-EFFORT (non-blocking in CI): depends on
|
|
2
2
|
// a paywall actually rendering for the configured placement against the real backend.
|
|
3
3
|
// Mirrors E2E_TEST_INDEX T8/T12 adapted to the Cordova imperative API.
|
|
4
|
-
const {
|
|
4
|
+
const {
|
|
5
|
+
waitForPurchaselyReady,
|
|
6
|
+
displayPresentation,
|
|
7
|
+
awaitDismissOutcome,
|
|
8
|
+
closeCurrentPresentation,
|
|
9
|
+
} = require('../helpers/driver');
|
|
5
10
|
|
|
6
11
|
const PLACEMENT = process.env.PURCHASELY_E2E_PLACEMENT || 'ONBOARDING';
|
|
7
12
|
|
|
@@ -10,28 +15,43 @@ describe('Presentation dismiss outcome', () => {
|
|
|
10
15
|
await waitForPurchaselyReady();
|
|
11
16
|
});
|
|
12
17
|
|
|
13
|
-
// T8/T12 —
|
|
18
|
+
// T8/T12 — display() resolves with the per-presentation dismiss outcome (was the
|
|
19
|
+
// presentPresentationForPlacement success callback; closePresentation() is now
|
|
20
|
+
// request.close(), driven here via closeCurrentPresentation()).
|
|
14
21
|
// Present a placement, close it programmatically, and assert the outcome fires with a
|
|
15
22
|
// closeReason.
|
|
16
|
-
it('
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
[PLACEMENT, null, 'fullScreen'],
|
|
21
|
-
90000
|
|
22
|
-
);
|
|
23
|
+
it('presentation.placement(...).build().display() + request.close() delivers a dismiss outcome', async () => {
|
|
24
|
+
// Fire display() fire-and-forget (its promise settles at dismiss and would otherwise
|
|
25
|
+
// block the session so close() could never run).
|
|
26
|
+
await displayPresentation('placement', PLACEMENT, 'fullScreen');
|
|
23
27
|
|
|
24
28
|
// Give the paywall time to render, then close it programmatically from the bridge.
|
|
25
29
|
await browser.pause(6000);
|
|
26
|
-
await
|
|
30
|
+
await closeCurrentPresentation();
|
|
27
31
|
|
|
28
|
-
const outcome = await
|
|
32
|
+
const outcome = await awaitDismissOutcome(30000);
|
|
29
33
|
expect(outcome.ok).toBe(true);
|
|
30
34
|
expect(outcome.value).toBeDefined();
|
|
31
35
|
// v6 outcome carries a closeReason; programmatic close => 'programmatic' (Android).
|
|
32
36
|
if (outcome.value && outcome.value.closeReason) {
|
|
33
37
|
expect(typeof outcome.value.closeReason).toBe('string');
|
|
34
38
|
}
|
|
35
|
-
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// v6 default (audience-targeted) presentation: same shape as the placement flow above,
|
|
42
|
+
// just with no placement/screen id (was presentPresentationForDefault). Best-effort:
|
|
43
|
+
// depends on a default audience being configured on the backend for this app id.
|
|
44
|
+
it('presentation.defaultSource().build().display() + request.close() delivers a dismiss outcome', async () => {
|
|
45
|
+
await displayPresentation('defaultSource', null, 'fullScreen');
|
|
46
|
+
|
|
47
|
+
await browser.pause(6000);
|
|
48
|
+
await closeCurrentPresentation();
|
|
49
|
+
|
|
50
|
+
const outcome = await awaitDismissOutcome(30000);
|
|
51
|
+
expect(outcome.ok).toBe(true);
|
|
52
|
+
expect(outcome.value).toBeDefined();
|
|
53
|
+
if (outcome.value && outcome.value.closeReason) {
|
|
54
|
+
expect(typeof outcome.value.closeReason).toBe('string');
|
|
55
|
+
}
|
|
36
56
|
});
|
|
37
57
|
});
|
|
@@ -15,8 +15,22 @@ LOGDIR="$HERE/ci-logs"
|
|
|
15
15
|
mkdir -p "$LOGDIR"
|
|
16
16
|
export ANDROID_SERIAL="$SERIAL"
|
|
17
17
|
|
|
18
|
+
# Appium 2 loads drivers from APPIUM_HOME (~/.appium), not node_modules, so a fresh CI
|
|
19
|
+
# checkout has none installed even though appium-uiautomator2-driver is a devDependency.
|
|
20
|
+
# Register it (idempotent; a no-op locally where it is already installed).
|
|
21
|
+
echo "== Ensuring uiautomator2 driver is installed =="
|
|
22
|
+
npx appium driver install uiautomator2 2>/dev/null || true
|
|
23
|
+
|
|
18
24
|
echo "== Starting Appium =="
|
|
19
|
-
|
|
25
|
+
# --allow-insecure=chromedriver_autodownload lets the uiautomator2 driver fetch the
|
|
26
|
+
# Chromedriver matching the Cordova WebView's Chrome version on demand. Without it,
|
|
27
|
+
# switching to the WEBVIEW context fails ("No Chromedriver found that can automate
|
|
28
|
+
# Chrome 'X'"), which breaks every bridge test in its `before all` hook.
|
|
29
|
+
# Detach Appium's stdout/stderr (it logs to --log anyway): if it keeps the script's
|
|
30
|
+
# output pipe open, the android-emulator-runner action hangs after the tests finish
|
|
31
|
+
# instead of returning.
|
|
32
|
+
npx appium --allow-insecure=uiautomator2:chromedriver_autodownload \
|
|
33
|
+
--log "$LOGDIR/appium-android.log" --log-level info >/dev/null 2>&1 &
|
|
20
34
|
APPIUM_PID=$!
|
|
21
35
|
trap 'kill $APPIUM_PID 2>/dev/null || true' EXIT
|
|
22
36
|
# Wait for Appium to accept connections.
|
|
@@ -13,8 +13,15 @@ LOGDIR="$HERE/ci-logs"
|
|
|
13
13
|
mkdir -p "$LOGDIR"
|
|
14
14
|
export PURCHASELY_E2E_UDID="$UDID"
|
|
15
15
|
|
|
16
|
+
# Appium 2 loads drivers from APPIUM_HOME (~/.appium), not node_modules, so make sure the
|
|
17
|
+
# xcuitest driver is registered (idempotent; a no-op where it is already installed).
|
|
18
|
+
echo "== Ensuring xcuitest driver is installed =="
|
|
19
|
+
npx appium driver install xcuitest 2>/dev/null || true
|
|
20
|
+
|
|
16
21
|
echo "== Starting Appium =="
|
|
17
|
-
|
|
22
|
+
# Detach Appium's stdout/stderr (it logs to --log anyway) so it can't hold the runner's
|
|
23
|
+
# output pipe open after the tests finish.
|
|
24
|
+
npx appium --log "$LOGDIR/appium-ios.log" --log-level info >/dev/null 2>&1 &
|
|
18
25
|
APPIUM_PID=$!
|
|
19
26
|
trap 'kill $APPIUM_PID 2>/dev/null || true' EXIT
|
|
20
27
|
for i in $(seq 1 30); do
|
|
@@ -23,7 +30,10 @@ for i in $(seq 1 30); do
|
|
|
23
30
|
done
|
|
24
31
|
|
|
25
32
|
run_suite() { # $1 = spec, $2 = hard|soft
|
|
26
|
-
|
|
33
|
+
# More retries than Android: WebDriverAgent's cold first build (~10-13 min) can outlast a
|
|
34
|
+
# few session-creation attempts, and the hard-gate bridge spec runs first — extra tries let
|
|
35
|
+
# WDA finish building (it is then cached via derivedDataPath, so later specs are instant).
|
|
36
|
+
local spec="$1" gate="$2" tries="${E2E_TRIES:-6}" n=1
|
|
27
37
|
while [ $n -le $tries ]; do
|
|
28
38
|
echo "== [$gate] $spec (attempt $n/$tries) =="
|
|
29
39
|
if npx wdio run ./wdio.ios.conf.js --spec "$spec" 2>&1 | tee "$LOGDIR/wdio-$(basename "$spec").log"; then
|
|
@@ -11,6 +11,9 @@ exports.config = Object.assign({}, config, {
|
|
|
11
11
|
'appium:automationName': 'UiAutomator2',
|
|
12
12
|
'appium:app': APK,
|
|
13
13
|
'appium:appPackage': 'com.purchasely.demo',
|
|
14
|
+
// Pin the target device when several are attached (env override); CI has a
|
|
15
|
+
// single emulator so this is normally undefined.
|
|
16
|
+
'appium:udid': process.env.PURCHASELY_E2E_UDID || undefined,
|
|
14
17
|
'appium:newCommandTimeout': 240,
|
|
15
18
|
'appium:autoGrantPermissions': true,
|
|
16
19
|
// The Cordova WebView is debuggable in the debug build, so Appium can attach
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
2
4
|
const { config } = require('./wdio.shared.conf');
|
|
3
5
|
|
|
4
6
|
// Path to the .app built by `cordova build ios --emulator` (simulator build).
|
|
7
|
+
// cordova-ios 8.x emits it under build/Debug-iphonesimulator; older cordova-ios
|
|
8
|
+
// used build/emulator. Prefer whichever exists so the suite survives toolchain bumps.
|
|
9
|
+
const APP_CANDIDATES = [
|
|
10
|
+
'../platforms/ios/build/Debug-iphonesimulator/HelloCordova.app',
|
|
11
|
+
'../platforms/ios/build/emulator/HelloCordova.app',
|
|
12
|
+
].map((p) => path.resolve(__dirname, p));
|
|
5
13
|
const APP = process.env.PURCHASELY_E2E_APP ||
|
|
6
|
-
|
|
14
|
+
APP_CANDIDATES.find((p) => fs.existsSync(p)) ||
|
|
15
|
+
APP_CANDIDATES[0];
|
|
7
16
|
|
|
8
17
|
exports.config = Object.assign({}, config, {
|
|
9
18
|
capabilities: [{
|
|
@@ -12,8 +21,24 @@ exports.config = Object.assign({}, config, {
|
|
|
12
21
|
'appium:app': APP,
|
|
13
22
|
'appium:bundleId': 'com.purchasely.demo',
|
|
14
23
|
'appium:deviceName': process.env.PURCHASELY_E2E_SIM || 'iPhone 16',
|
|
24
|
+
// Pin an already-booted simulator when provided (CI boots one and exports its udid).
|
|
25
|
+
'appium:udid': process.env.PURCHASELY_E2E_UDID || undefined,
|
|
15
26
|
'appium:platformVersion': process.env.PURCHASELY_E2E_IOS_VERSION || undefined,
|
|
16
27
|
'appium:newCommandTimeout': 240,
|
|
17
28
|
'appium:autoAcceptAlerts': true,
|
|
29
|
+
// WebDriverAgent's FIRST build on a cold CI runner can take several minutes; even 240s
|
|
30
|
+
// wasn't enough ("Unable to start WebDriverAgent ... after 240000ms"), so the first spec
|
|
31
|
+
// burned all its retries before WDA finished building. Give one attempt a long window to
|
|
32
|
+
// build WDA, then reuse it (useNewWDA:false) — later specs start in a few seconds.
|
|
33
|
+
'appium:wdaLaunchTimeout': 600000,
|
|
34
|
+
'appium:wdaConnectionTimeout': 600000,
|
|
35
|
+
'appium:wdaStartupRetries': 1,
|
|
36
|
+
'appium:wdaStartupRetryInterval': 20000,
|
|
37
|
+
'appium:useNewWDA': false,
|
|
38
|
+
// Build WebDriverAgent into a FIXED DerivedData dir so it is compiled once and reused
|
|
39
|
+
// across spec retries. Without it appium uses a fresh temp dir per session, so WDA is
|
|
40
|
+
// rebuilt from scratch every attempt and the hard-gate bridge spec times out before the
|
|
41
|
+
// (slow, cold) build ever finishes.
|
|
42
|
+
'appium:derivedDataPath': path.join(os.tmpdir(), 'ply-wda-derived'),
|
|
18
43
|
}],
|
|
19
44
|
});
|
package/example/ios.sh
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
cordova
|
|
5
|
-
cordova platform
|
|
6
|
-
cordova
|
|
3
|
+
npm ci
|
|
4
|
+
cordova plugin remove @purchasely/cordova-plugin-purchasely --nosave
|
|
5
|
+
cordova platform remove ios --nosave
|
|
6
|
+
cordova platform add ios@8.1.1 --nosave
|
|
7
|
+
cordova plugin add ../ --link --nosave
|
|
7
8
|
|
|
8
9
|
if [[ $1 = true ]]
|
|
9
10
|
then
|
|
@@ -13,4 +14,4 @@ then
|
|
|
13
14
|
pod repo update
|
|
14
15
|
echo "Installing Purchasely SDK"
|
|
15
16
|
pod install --project-directory=platforms/ios
|
|
16
|
-
fi
|
|
17
|
+
fi
|
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
"@purchasely/cordova-plugin-purchasely": "file:..",
|
|
13
13
|
"@purchasely/cordova-plugin-purchasely-google": "file:../../purchasely-google",
|
|
14
14
|
"cordova-android": "^15.0.0",
|
|
15
|
-
"cordova-ios": "
|
|
15
|
+
"cordova-ios": "8.1.1",
|
|
16
16
|
"cordova-plugin-purchasely": "file:..",
|
|
17
17
|
"cordova-plugin-purchasely-google": "file:../../purchasely-google"
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
20
|
"..": {
|
|
21
21
|
"name": "@purchasely/cordova-plugin-purchasely",
|
|
22
|
-
"version": "6.0.0
|
|
22
|
+
"version": "6.0.0",
|
|
23
23
|
"dev": true,
|
|
24
24
|
"license": "ISC",
|
|
25
25
|
"devDependencies": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"../../purchasely-google": {
|
|
38
38
|
"name": "@purchasely/cordova-plugin-purchasely-google",
|
|
39
|
-
"version": "6.0.0
|
|
39
|
+
"version": "6.0.0",
|
|
40
40
|
"dev": true,
|
|
41
41
|
"license": "ISC"
|
|
42
42
|
},
|
package/example/package.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"@purchasely/cordova-plugin-purchasely": "file:..",
|
|
17
17
|
"@purchasely/cordova-plugin-purchasely-google": "file:../../purchasely-google",
|
|
18
18
|
"cordova-android": "^15.0.0",
|
|
19
|
-
"cordova-ios": "
|
|
19
|
+
"cordova-ios": "8.1.1",
|
|
20
20
|
"cordova-plugin-purchasely": "file:..",
|
|
21
21
|
"cordova-plugin-purchasely-google": "file:../../purchasely-google"
|
|
22
22
|
},
|
|
@@ -34,4 +34,4 @@
|
|
|
34
34
|
"android"
|
|
35
35
|
]
|
|
36
36
|
}
|
|
37
|
-
}
|
|
37
|
+
}
|