@amplytools/react-native-amply-sdk 0.11.0 → 0.12.1

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,263 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Every control the sample spec requires must exist in every sample that claims the
4
+ # platform, tagged with its manifest id. Nothing else notices when one of them is missing.
5
+ #
6
+ # The six Amply samples are six copies of the same screen, and they drifted because
7
+ # copy-paste has no compiler. In the KMP repo, `ios-spm-sample/HomeViewController.swift`
8
+ # reached 886 lines while its CocoaPods twin sat at 566 — the same screen, forked and then
9
+ # edited on one side only, with `GateDemo.swift` existing in exactly one of them. Both
10
+ # projects built clean the whole time. Nobody noticed, because nothing checked. The two RN
11
+ # examples here forked the same way: `bare/src/hooks/useAmplyDemo.ts` and
12
+ # `expo/src/hooks/useAmplyDemo.ts` are two copies of one hook, and `bare` still imports the
13
+ # SDK under a legacy alias no integrator would type.
14
+ #
15
+ # This is that check. It greps each example's own sources for every manifest id required on
16
+ # the `rn` column and fails naming the id, the example, and where it looked. It is a parity
17
+ # check, not a UI test: it answers "is this control present in this sample", which is the
18
+ # only question consistency actually turns on.
19
+ #
20
+ # Spec: multiplatform-library-template/samples/SAMPLE_APP_SPEC.md (canonical, KMP repo)
21
+ # Manifest: example/sample-manifest.txt (verbatim copy of the KMP repo's canonical file;
22
+ # this script diffs the two when both are present)
23
+ #
24
+ # Usage: bash scripts/check-sample-parity.sh
25
+ set -uo pipefail
26
+
27
+ cd "$(dirname "$0")/.."
28
+
29
+ MANIFEST="example/sample-manifest.txt"
30
+ SPEC_PATH="multiplatform-library-template/samples/SAMPLE_APP_SPEC.md"
31
+
32
+ if [ ! -f "$MANIFEST" ]; then
33
+ echo "check-sample-parity: FAIL — missing $MANIFEST" >&2
34
+ exit 1
35
+ fi
36
+
37
+ # Quote characters an id can be wrapped in: JSX uses ", JS also uses ' and ` for template
38
+ # literals. The id literal must sit next to `testID` on one line.
39
+ Q="[\"'\`]"
40
+
41
+ # ---------------------------------------------------------------------------------------
42
+ # The canonical copy. The manifest lives in the KMP repo; this repo carries a duplicate
43
+ # because it is published to npm and mirrored publicly and so cannot depend on a sibling
44
+ # checkout existing. Duplication that nothing checks is drift with extra steps, so: when
45
+ # the KMP repo is reachable, diff it and fail on any difference. When it is not, say so
46
+ # loudly and print the revision, so a stale copy shows up in the output instead of passing
47
+ # silently.
48
+ # ---------------------------------------------------------------------------------------
49
+ revision=$(grep -m1 '^# revision:' "$MANIFEST" | sed 's/^# revision:[[:space:]]*//')
50
+ if [ -z "$revision" ]; then
51
+ echo "check-sample-parity: FAIL — $MANIFEST has no '# revision:' header" >&2
52
+ exit 1
53
+ fi
54
+
55
+ peer=""
56
+ for candidate in \
57
+ "${AMPLY_SAMPLE_MANIFEST_PEER:-}" \
58
+ "../multiplatform-library-template/samples/sample-manifest.txt"
59
+ do
60
+ [ -n "$candidate" ] && [ -f "$candidate" ] && { peer="$candidate"; break; }
61
+ done
62
+
63
+ if [ -n "$peer" ]; then
64
+ if ! diff -u "$peer" "$MANIFEST" > /tmp/check-sample-parity-peer.diff 2>&1; then
65
+ echo "check-sample-parity: FAIL — this repo's manifest copy has diverged from the canonical one." >&2
66
+ echo " canonical: $peer" >&2
67
+ echo " copy: $MANIFEST" >&2
68
+ sed 's/^/ /' /tmp/check-sample-parity-peer.diff >&2
69
+ rm -f /tmp/check-sample-parity-peer.diff
70
+ exit 1
71
+ fi
72
+ rm -f /tmp/check-sample-parity-peer.diff
73
+ peer_state="verified byte-identical against $peer"
74
+ else
75
+ peer_state="NOT VERIFIED — the KMP repo was not found; set AMPLY_SAMPLE_MANIFEST_PEER to check it"
76
+ fi
77
+
78
+ # ---------------------------------------------------------------------------------------
79
+ # Parse the manifest into: id <TAB> android <TAB> ios <TAB> rn <TAB> note
80
+ # ---------------------------------------------------------------------------------------
81
+ rows=$(
82
+ awk -F'|' '
83
+ /^[[:space:]]*#/ { next }
84
+ /^[[:space:]]*$/ { next }
85
+ NF < 4 { next }
86
+ {
87
+ for (i = 1; i <= NF; i++) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", $i) }
88
+ note = (NF >= 5) ? $5 : ""
89
+ print $1 "\t" $2 "\t" $3 "\t" $4 "\t" note
90
+ }
91
+ ' "$MANIFEST"
92
+ )
93
+
94
+ if [ -z "$rows" ]; then
95
+ echo "check-sample-parity: FAIL — parsed zero rows from $MANIFEST; the parser is broken" >&2
96
+ exit 1
97
+ fi
98
+
99
+ row_count=$(printf '%s\n' "$rows" | wc -l | tr -d ' ')
100
+
101
+ # ---------------------------------------------------------------------------------------
102
+ # The examples. Each one searches its own sources plus the shared UI module. An absent root
103
+ # is skipped rather than fatal; an example with NO existing root at all is fatal, because
104
+ # that means this list is stale.
105
+ #
106
+ # The shared module is on BOTH examples' search paths, and that is a trap this check walks
107
+ # into unless it is told not to. A control id living in example/shared satisfies the grep
108
+ # for bare and expo alike — including for a host that never imports the module and still
109
+ # renders its own old screen. That is not hypothetical: on the day the shared module landed,
110
+ # `expo` reported "all 40 required controls present" while app/index.tsx was still building
111
+ # its own ScrollView out of useAmplyDemo. Green, and completely wrong.
112
+ #
113
+ # The native samples do not have this hole. Both Xcode projects list ../ios-shared/Sources
114
+ # and both Gradle modules include the shared srcDir, so on those platforms presence in the
115
+ # shared source IS compilation into the target. Metro resolving a package is not the same
116
+ # claim: a module on the search path is only rendered if something imports it.
117
+ #
118
+ # So each RN host must also prove it MOUNTS the shared module. `mount` below is a file that
119
+ # must exist and must import SHARED_PKG. Without this, the consolidation can be reported
120
+ # complete while half of it was never wired.
121
+ # ---------------------------------------------------------------------------------------
122
+ SHARED_PKG="@amply/sample-shared"
123
+ samples=(
124
+ "bare|example/bare/src example/shared|example/bare/src/App.tsx"
125
+ "expo|example/expo/app example/expo/src example/expo/components example/shared|example/expo/app/index.tsx"
126
+ )
127
+
128
+ ATTR="testID"
129
+ INCLUDES="--include=*.ts --include=*.tsx --include=*.js --include=*.jsx"
130
+
131
+ fail=0
132
+ checked_total=0
133
+
134
+ echo "check-sample-parity: manifest revision $revision — $row_count rows"
135
+ echo " peer copy: $peer_state"
136
+ echo
137
+
138
+ for entry in "${samples[@]}"; do
139
+ name="${entry%%|*}"
140
+ rest="${entry#*|}"
141
+ roots="${rest%%|*}"
142
+ mount="${rest#*|}"
143
+
144
+ existing_roots=""
145
+ for r in $roots; do
146
+ [ -d "$r" ] && existing_roots="$existing_roots $r"
147
+ done
148
+
149
+ if [ -z "$existing_roots" ]; then
150
+ echo "check-sample-parity: FAIL — example '$name' has no source root on disk (looked in: $roots)" >&2
151
+ echo " the sample list in this script is stale." >&2
152
+ fail=1
153
+ continue
154
+ fi
155
+
156
+ # Does this host actually MOUNT the shared module, or does it merely have it on a search
157
+ # path? Only the second is true of a host still rendering its own screen, and the id grep
158
+ # below cannot tell the difference — every shared id would satisfy it either way.
159
+ if [ -d "example/shared" ]; then
160
+ if [ ! -f "$mount" ]; then
161
+ echo "check-sample-parity: FAIL — example '$name' has no entry point at $mount" >&2
162
+ echo " the mount path in this script is stale." >&2
163
+ fail=1
164
+ continue
165
+ fi
166
+ if ! grep -qF "$SHARED_PKG" "$mount"; then
167
+ echo "check-sample-parity: FAIL — example '$name' does not mount the shared UI module." >&2
168
+ echo " $mount never imports $SHARED_PKG, so this example still" >&2
169
+ echo " renders its own screen. Every control id below would be" >&2
170
+ echo " satisfied by example/shared regardless — a green that means" >&2
171
+ echo " nothing. Mount the shared module in $mount." >&2
172
+ fail=1
173
+ continue
174
+ fi
175
+ fi
176
+
177
+ missing=()
178
+ unbound=()
179
+ required=0
180
+
181
+ while IFS=$'\t' read -r id android ios rn note; do
182
+ [ "$rn" = "x" ] || continue
183
+ required=$((required + 1))
184
+
185
+ id_esc=$(printf '%s' "$id" | sed 's/[.[\*^$()+?{}|\\]/\\&/g')
186
+
187
+ case "$note" in
188
+ launcharg:*)
189
+ # Not a UI control — the launch key just has to appear in the source. On RN the
190
+ # transport is a deeplink query param rather than a launch argument (neither
191
+ # example can read one without a new native dependency); the key spelling is the
192
+ # same, which is why one manifest row covers all three platforms.
193
+ # shellcheck disable=SC2086
194
+ if ! grep -RIlE $INCLUDES -e "$id_esc" $existing_roots > /dev/null 2>&1; then
195
+ missing+=("$id")
196
+ fi
197
+ continue
198
+ ;;
199
+ prefix:*) pattern="${Q}${id_esc}\\." ;;
200
+ *) pattern="${Q}${id_esc}${Q}" ;;
201
+ esac
202
+
203
+ # shellcheck disable=SC2086
204
+ hits=$(grep -RInE $INCLUDES -e "$pattern" $existing_roots 2>/dev/null)
205
+ if [ -z "$hits" ]; then
206
+ missing+=("$id")
207
+ continue
208
+ fi
209
+ if ! printf '%s\n' "$hits" | grep -q "$ATTR"; then
210
+ unbound+=("$id")
211
+ fi
212
+ done <<< "$rows"
213
+
214
+ checked_total=$((checked_total + required))
215
+
216
+ if [ ${#missing[@]} -eq 0 ] && [ ${#unbound[@]} -eq 0 ]; then
217
+ echo " OK $name (rn) — all $required required controls present"
218
+ continue
219
+ fi
220
+
221
+ fail=1
222
+ echo " FAIL $name (rn) — $((${#missing[@]} + ${#unbound[@]})) of $required required controls not satisfied"
223
+ echo " searched:$existing_roots"
224
+ if [ ${#missing[@]} -gt 0 ]; then
225
+ echo " missing (${#missing[@]}):"
226
+ for id in "${missing[@]}"; do echo " - $id"; done
227
+ fi
228
+ if [ ${#unbound[@]} -gt 0 ]; then
229
+ echo " present but not on a line with \`$ATTR\` (${#unbound[@]}):"
230
+ for id in "${unbound[@]}"; do echo " - $id"; done
231
+ fi
232
+ echo
233
+ done
234
+
235
+ # ---------------------------------------------------------------------------------------
236
+ # Retired ids: controls that were required once and must NOT come back. A presence-only
237
+ # check cannot see a control that should have been deleted, so without this a removal is
238
+ # held together by trust. Failed on sight, wherever they appear.
239
+ # ---------------------------------------------------------------------------------------
240
+ retired_ids=$(grep -E '^# +retired: +amply\.' "$MANIFEST" | sed -E 's/^# +retired: +([^ ]+).*/\1/')
241
+ for rid in $retired_ids; do
242
+ rid_esc=$(printf '%s' "$rid" | sed 's/[.[\*^$()+?{}|\\]/\\&/g')
243
+ # shellcheck disable=SC2086
244
+ retired_roots=""
245
+ for r in example/bare/src example/expo/app example/expo/src example/expo/components example/shared; do [ -d "$r" ] && retired_roots="$retired_roots $r"; done
246
+ # shellcheck disable=SC2086
247
+ hits=$(grep -RIlE --include=*.ts --include=*.tsx --include=*.js --include=*.jsx -e "${Q}${rid_esc}${Q}" $retired_roots 2>/dev/null || true)
248
+ if [ -n "$hits" ]; then
249
+ echo "check-sample-parity: FAIL — retired control '$rid' is still in the source:" >&2
250
+ printf ' %s\n' $hits >&2
251
+ echo " It was deliberately removed; see the manifest's Retired block." >&2
252
+ fail=1
253
+ fi
254
+ done
255
+
256
+ if [ "$fail" -ne 0 ]; then
257
+ echo "check-sample-parity: FAIL" >&2
258
+ echo " Each missing id is a control $SPEC_PATH requires and the example" >&2
259
+ echo " does not have. Tag the real widget with it: testID=\"<id>\"." >&2
260
+ exit 1
261
+ fi
262
+
263
+ echo "check-sample-parity: OK — manifest revision $revision, $checked_total control checks across ${#samples[@]} examples."
@@ -221,7 +221,7 @@ describe('gate registration lifecycle', () => {
221
221
  expect(mockNativeModule.registerGate).not.toHaveBeenCalled();
222
222
 
223
223
  mockNativeModule.isInitialized.mockReturnValue(true);
224
- await Amply.initialize({apiKey: 'k', apiKeySecret: 's'} as never);
224
+ await Amply.initialize({appId: 'a', apiKeyPublic: 'k', apiKeySecret: 's'});
225
225
 
226
226
  expect(mockNativeModule.registerGate).toHaveBeenCalledWith('stillframe://ad', 'cancel', 0);
227
227
 
@@ -237,7 +237,7 @@ describe('gate registration lifecycle', () => {
237
237
  expect(mockNativeModule.registerDeepLinkListener).not.toHaveBeenCalled();
238
238
 
239
239
  mockNativeModule.isInitialized.mockReturnValue(true);
240
- await Amply.initialize({apiKey: 'k', apiKeySecret: 's'} as never);
240
+ await Amply.initialize({appId: 'a', apiKeyPublic: 'k', apiKeySecret: 's'});
241
241
 
242
242
  expect(mockNativeModule.registerDeepLinkListener).toHaveBeenCalledTimes(1);
243
243
  });
@@ -257,7 +257,7 @@ describe('gate registration lifecycle', () => {
257
257
  await Amply.registerGate('stillframe://ad', second);
258
258
 
259
259
  mockNativeModule.isInitialized.mockReturnValue(true);
260
- await Amply.initialize({apiKey: 'k', apiKeySecret: 's'} as never);
260
+ await Amply.initialize({appId: 'a', apiKeyPublic: 'k', apiKeySecret: 's'});
261
261
 
262
262
  emitPresentation();
263
263
  await flushMicrotasks();
@@ -273,7 +273,7 @@ describe('gate registration lifecycle', () => {
273
273
  unsubscribe();
274
274
 
275
275
  mockNativeModule.isInitialized.mockReturnValue(true);
276
- await Amply.initialize({apiKey: 'k', apiKeySecret: 's'} as never);
276
+ await Amply.initialize({appId: 'a', apiKeyPublic: 'k', apiKeySecret: 's'});
277
277
 
278
278
  expect(mockNativeModule.registerGate).not.toHaveBeenCalled();
279
279
  });
@@ -29,17 +29,43 @@ describe('Amply JS API', () => {
29
29
  });
30
30
 
31
31
  it('initializes the native module', async () => {
32
- await Amply.initialize({ appId: 'id', apiKeyPublic: 'public' });
32
+ await Amply.initialize({ appId: 'id', apiKeyPublic: 'public', apiKeySecret: 'secret' });
33
33
 
34
34
  expect(mockNativeModule.initialize).toHaveBeenCalledWith(
35
35
  expect.objectContaining({ appId: 'id', apiKeyPublic: 'public' })
36
36
  );
37
37
  });
38
38
 
39
+ // A missing key used to reach the native SDK, whose config builder threw an exception that
40
+ // crossed into Objective-C uncaught and aborted the host app — no JS try/catch could stop it.
41
+ // Bad keys must cost the app its Amply, never its process.
42
+ describe.each([
43
+ ['apiKeySecret', { appId: 'id', apiKeyPublic: 'public' }],
44
+ ['apiKeySecret', { appId: 'id', apiKeyPublic: 'public', apiKeySecret: '' }],
45
+ ['apiKeySecret', { appId: 'id', apiKeyPublic: 'public', apiKeySecret: ' ' }],
46
+ ['apiKeySecret', { appId: 'id', apiKeyPublic: 'public', apiKeySecret: null }],
47
+ ['apiKeyPublic', { appId: 'id', apiKeyPublic: '', apiKeySecret: 'secret' }],
48
+ ['appId', { appId: '', apiKeyPublic: 'public', apiKeySecret: 'secret' }],
49
+ ])('when %s is missing (%j)', (field, config) => {
50
+ it('rejects with AMP_INVALID_CONFIG, names the field and never calls the native module', async () => {
51
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
52
+
53
+ await expect(Amply.initialize(config as never)).rejects.toMatchObject({
54
+ code: 'AMP_INVALID_CONFIG',
55
+ message: expect.stringContaining(field),
56
+ });
57
+ expect(mockNativeModule.initialize).not.toHaveBeenCalled();
58
+ expect(error).toHaveBeenCalledWith(expect.stringContaining(field));
59
+
60
+ error.mockRestore();
61
+ });
62
+ });
63
+
39
64
  it('passes the base-URL overrides through to the native layer', async () => {
40
65
  await Amply.initialize({
41
66
  appId: 'id',
42
67
  apiKeyPublic: 'public',
68
+ apiKeySecret: 'secret',
43
69
  configBaseUrl: 'https://config-staging.example.com',
44
70
  backendBaseUrl: 'https://api-staging.example.com',
45
71
  });
@@ -59,6 +85,7 @@ describe('Amply JS API', () => {
59
85
  await Amply.initialize({
60
86
  appId: 'id',
61
87
  apiKeyPublic: 'public',
88
+ apiKeySecret: 'secret',
62
89
  endpoint: 'https://api-staging.example.com',
63
90
  });
64
91
 
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ export type {FormatOptions} from './systemEventUtils';
6
6
  import type {
7
7
  CampaignResult,
8
8
  GateDecision,
9
- AmplyInitializationConfig,
9
+ AmplyInitializationConfig as NativeInitializationConfig,
10
10
  DataSetSnapshot,
11
11
  DataSetType,
12
12
  DeepLinkEvent,
@@ -16,6 +16,36 @@ import type {
16
16
  TrackEventPayload,
17
17
  } from './nativeSpecs/NativeAmplyModule';
18
18
 
19
+ /**
20
+ * The keys from your application's page in Amply. All three are required: the SDK signs every
21
+ * request with `apiKeySecret` on the device, so the secret ships inside the app next to the
22
+ * public key. The codegen spec keeps the field optional only so the generated native structs
23
+ * stay unchanged; this is the contract callers see.
24
+ */
25
+ export type AmplyInitializationConfig = Omit<NativeInitializationConfig, 'apiKeySecret'> & {
26
+ apiKeySecret: string;
27
+ };
28
+
29
+ const REQUIRED_KEYS = ['appId', 'apiKeyPublic', 'apiKeySecret'] as const;
30
+
31
+ /** Rejection raised by {@link initialize} for a config the SDK cannot start with. */
32
+ export class AmplyConfigError extends Error {
33
+ readonly code = 'AMP_INVALID_CONFIG';
34
+
35
+ constructor(message: string) {
36
+ super(message);
37
+ this.name = 'AmplyConfigError';
38
+ }
39
+ }
40
+
41
+ /** The first required key that is missing or blank, if any. */
42
+ function missingKey(config: Partial<Record<(typeof REQUIRED_KEYS)[number], unknown>>): string | undefined {
43
+ return REQUIRED_KEYS.find((key) => {
44
+ const value = config?.[key];
45
+ return typeof value !== 'string' || value.trim().length === 0;
46
+ });
47
+ }
48
+
19
49
  let deepLinkRegistered = false;
20
50
  let debugLogListenerRegistered = false;
21
51
  const deepLinkSubscriptions = new Set<() => void>();
@@ -189,6 +219,15 @@ function trackDeepLinkSubscription(subscription?: {remove?: () => void}): () =>
189
219
  }
190
220
 
191
221
  export async function initialize(config: AmplyInitializationConfig): Promise<void> {
222
+ // Checked here, before the native module sees anything: a missing key used to reach the
223
+ // native config builder, which threw across the Objective-C boundary and aborted the host
224
+ // app. Bad keys cost the app its Amply — never its process. The SDK stays uninitialized.
225
+ const missing = missingKey(config);
226
+ if (missing) {
227
+ const message = `[Amply] '${missing}' is required — Amply is not initialized. Copy it from your application's page in Amply.`;
228
+ console.error(message);
229
+ throw new AmplyConfigError(message);
230
+ }
192
231
  // Set up debug log listener if debug mode is enabled
193
232
  if (config.debug || config.logLevel) {
194
233
  ensureDebugLogListener();
@@ -570,7 +609,6 @@ export function removeAllListeners(): void {
570
609
  }
571
610
 
572
611
  export type {
573
- AmplyInitializationConfig,
574
612
  DataSetSnapshot,
575
613
  DataSetType,
576
614
  DeepLinkEvent,