@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.
@@ -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.
@@ -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
- return browser.executeAsync(
40
- function (method, args, timeoutMs, done) {
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
- timeoutMs
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 = { switchToWebview, switchToNative, waitForPurchaselyReady, callBridge };
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
- expect(res.ok).toBe(true);
25
- expect(Array.isArray(res.value)).toBe(true);
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
- it('fetchPresentationForPlacement returns a presentation object', async () => {
30
- const res = await callBridge('fetchPresentationForPlacement', [PLACEMENT, null]);
31
- expect(res.ok).toBe(true);
32
- expect(res.value).toBeDefined();
33
- // v6 presentation carries an id/type so it can later be displayed.
34
- expect(res.value === null || typeof res.value === 'object').toBe(true);
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 callBridge('setUserAttributeWithString', ['e2e_key', 'e2e_value', 'ESSENTIAL']);
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 { waitForPurchaselyReady, callBridge, switchToNative } = require('../helpers/driver');
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 — the present* success callback IS the per-presentation dismiss outcome.
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('presentPresentationForPlacement + closePresentation delivers a dismiss outcome', async () => {
17
- // Kick off the presentation; do NOT await (the callback resolves at dismiss).
18
- const outcomePromise = callBridge(
19
- 'presentPresentationForPlacement',
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 callBridge('closePresentation');
30
+ await closeCurrentPresentation();
27
31
 
28
- const outcome = await outcomePromise;
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
- await switchToNative().catch(() => {});
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
- npx appium --log "$LOGDIR/appium-android.log" --log-level info &
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
- npx appium --log "$LOGDIR/appium-ios.log" --log-level info &
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
- local spec="$1" gate="$2" tries=3 n=1
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
- path.resolve(__dirname, '../platforms/ios/build/emulator/HelloCordova.app');
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
- cordova plugin remove @purchasely/cordova-plugin-purchasely
4
- cordova platform remove ios
5
- cordova platform add ios@latest
6
- cordova plugin add ../ --link
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": "^8.1.0",
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-rc.3",
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-rc.3",
39
+ "version": "6.0.0",
40
40
  "dev": true,
41
41
  "license": "ISC"
42
42
  },
@@ -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": "^8.1.0",
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
+ }