yaver-feedback-react-native 0.9.4 → 0.9.6

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.
Files changed (38) hide show
  1. package/README.md +31 -16
  2. package/android/src/main/java/io/yaver/feedback/YaverHotReloadModule.java +59 -0
  3. package/app.plugin.js +112 -3
  4. package/dist/AuthOverlay.js +13 -2
  5. package/dist/FeedbackModal.js +70 -7
  6. package/dist/YaverFeedback.d.ts +34 -9
  7. package/dist/YaverFeedback.js +304 -20
  8. package/dist/__tests__/NativeDogfoodShortcut.test.d.ts +1 -0
  9. package/dist/__tests__/NativeDogfoodShortcut.test.js +34 -0
  10. package/dist/__tests__/ReportIdentity.test.js +6 -3
  11. package/dist/__tests__/YaverFeedback.test.js +97 -4
  12. package/dist/__tests__/deviceDogfood.test.js +2 -2
  13. package/dist/__tests__/dogfoodPolicy.test.js +1 -1
  14. package/dist/auth.d.ts +7 -0
  15. package/dist/auth.js +28 -0
  16. package/dist/deviceDogfood.d.ts +6 -2
  17. package/dist/deviceDogfood.js +6 -3
  18. package/dist/dogfoodPolicy.d.ts +32 -1
  19. package/dist/index.d.ts +4 -4
  20. package/dist/index.js +5 -2
  21. package/dist/preferences.d.ts +2 -0
  22. package/dist/preferences.js +24 -0
  23. package/ios/YaverHotReload.m +8 -0
  24. package/ios/YaverHotReload.swift +37 -0
  25. package/package.json +2 -2
  26. package/src/AuthOverlay.tsx +11 -2
  27. package/src/FeedbackModal.tsx +98 -6
  28. package/src/YaverFeedback.ts +271 -19
  29. package/src/__tests__/NativeDogfoodShortcut.test.ts +37 -0
  30. package/src/__tests__/ReportIdentity.test.ts +6 -4
  31. package/src/__tests__/YaverFeedback.test.ts +104 -5
  32. package/src/__tests__/deviceDogfood.test.ts +2 -2
  33. package/src/__tests__/dogfoodPolicy.test.ts +1 -1
  34. package/src/auth.ts +29 -0
  35. package/src/deviceDogfood.ts +8 -3
  36. package/src/dogfoodPolicy.ts +32 -1
  37. package/src/index.ts +5 -2
  38. package/src/preferences.ts +20 -0
@@ -14,6 +14,7 @@ import {
14
14
  setStrictNativeAuth,
15
15
  getToken,
16
16
  getSelectedDeviceId,
17
+ getDogfoodAccountAccess,
17
18
  listReachableDevices,
18
19
  clearToken,
19
20
  clearSelectedDeviceId,
@@ -26,7 +27,7 @@ import {
26
27
  setQuickIconColorPreset,
27
28
  QuickIconColorPreset,
28
29
  } from './preferences';
29
- import { resolveSDKDogfood, type SDKDogfoodStatus } from './dogfoodPolicy';
30
+ import { resolveSDKDogfood, type SDKDogfoodStatus, type DogfoodAccessSnapshot } from './dogfoodPolicy';
30
31
  import { YaverDeviceDogfood, type DeviceDogfoodOptions, type DeviceDogfoodSession, type DeviceDogfoodState } from './deviceDogfood';
31
32
 
32
33
  export interface DogfoodOnboardingOptions extends DeviceDogfoodOptions {
@@ -36,6 +37,13 @@ export interface DogfoodOnboardingOptions extends DeviceDogfoodOptions {
36
37
  framework?: string;
37
38
  }
38
39
 
40
+ export type DogfoodFlowPhase = 'idle' | 'denied' | 'auth-required' | 'machine-required' | 'opening' | 'error';
41
+ export interface DogfoodFlowState {
42
+ phase: DogfoodFlowPhase;
43
+ appId?: string;
44
+ error?: string;
45
+ }
46
+
39
47
  function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
40
48
  const maybeNodeTimer = timer as unknown as { unref?: () => void };
41
49
  if (typeof maybeNodeTimer.unref === 'function') {
@@ -179,6 +187,50 @@ let autoStartTimer: ReturnType<typeof setTimeout> | null = null;
179
187
  let reportLaunchInFlight = false;
180
188
  let crashReportInFlight = false;
181
189
  let dogfoodOnboarding: DogfoodOnboardingOptions | null = null;
190
+ let dogfoodFlowState: DogfoodFlowState = { phase: 'idle' };
191
+ const dogfoodFlowListeners = new Set<(state: DogfoodFlowState) => void>();
192
+ let dogfoodShortcutAppStateSubscription: { remove: () => void } | null = null;
193
+ let dogfoodActivationSubscription: { remove: () => void } | null = null;
194
+ let dogfoodShortcutLaunchInFlight = false;
195
+ let lastDogfoodActivationUrl = '';
196
+
197
+ function publishDogfoodFlow(state: DogfoodFlowState): void {
198
+ dogfoodFlowState = state;
199
+ try { config?.dogfood?.onStateChange?.(state); } catch { /* host callback */ }
200
+ dogfoodFlowListeners.forEach((listener) => {
201
+ try { listener(state); } catch { /* host callbacks never break the SDK */ }
202
+ });
203
+ }
204
+
205
+ async function consumeNativeDogfoodShortcut(): Promise<void> {
206
+ if (dogfoodShortcutLaunchInFlight || !config?.dogfood?.appShortcut) return;
207
+ const native = (NativeModules as any)?.YaverHotReload;
208
+ if (typeof native?.consumeDogfoodShortcut !== 'function') return;
209
+ try {
210
+ if (!(await native.consumeDogfoodShortcut())) return;
211
+ dogfoodShortcutLaunchInFlight = true;
212
+ // Let FeedbackModal/AuthOverlay effects mount after a cold launch.
213
+ const timer = setTimeout(() => {
214
+ void YaverFeedback.openDogfood().finally(() => { dogfoodShortcutLaunchInFlight = false; });
215
+ }, 350);
216
+ unrefTimer(timer);
217
+ } catch {
218
+ dogfoodShortcutLaunchInFlight = false;
219
+ }
220
+ }
221
+
222
+ function handleDogfoodActivationUrl(url?: string | null): void {
223
+ if (!url || url === lastDogfoodActivationUrl || !config?.dogfood) return;
224
+ try {
225
+ const parsed = new URL(url);
226
+ if (!parsed.protocol.startsWith('yaver-dogfood-') || parsed.hostname !== 'activate') return;
227
+ } catch {
228
+ return;
229
+ }
230
+ lastDogfoodActivationUrl = url;
231
+ const timer = setTimeout(() => { void YaverFeedback.openDogfood(); }, 350);
232
+ unrefTimer(timer);
233
+ }
182
234
 
183
235
  /** Resolve the user's relay password by validating their auth token
184
236
  * against Convex. Used whenever we (re)build the P2PClient so a
@@ -438,6 +490,31 @@ export class YaverFeedback {
438
490
  void YaverFeedback.hydrateSession();
439
491
  }
440
492
 
493
+ if (config.dogfood?.appShortcut) {
494
+ void YaverFeedback.syncDogfoodAppShortcut();
495
+ void consumeNativeDogfoodShortcut();
496
+ try {
497
+ const { AppState } = require('react-native');
498
+ dogfoodShortcutAppStateSubscription?.remove();
499
+ dogfoodShortcutAppStateSubscription = AppState.addEventListener('change', (state: string) => {
500
+ if (state === 'active') {
501
+ void YaverFeedback.syncDogfoodAppShortcut();
502
+ void consumeNativeDogfoodShortcut();
503
+ }
504
+ });
505
+ } catch { /* native shortcut unavailable on web/test runtimes */ }
506
+ }
507
+ if (config.dogfood) {
508
+ try {
509
+ const { Linking } = require('react-native');
510
+ dogfoodActivationSubscription?.remove();
511
+ dogfoodActivationSubscription = Linking.addEventListener('url', ({ url }: { url: string }) => {
512
+ handleDogfoodActivationUrl(url);
513
+ });
514
+ void Linking.getInitialURL().then(handleDogfoodActivationUrl).catch(() => {});
515
+ } catch { /* linking unavailable in non-native test runtimes */ }
516
+ }
517
+
441
518
  // Create P2P client if we have a URL
442
519
  if (config.agentUrl) {
443
520
  p2pAuthToken = config.authToken ?? null;
@@ -634,7 +711,7 @@ export class YaverFeedback {
634
711
  * Sets config.agentUrl and creates P2PClient on success.
635
712
  */
636
713
  static async discoverAgent(): Promise<void> {
637
- if (!config || !enabled) return;
714
+ if (!config || (!enabled && !dogfoodOnboarding)) return;
638
715
  if (config.agentUrl) return; // already have a URL
639
716
  if (!config.authToken) return; // need auth before discovery can succeed
640
717
 
@@ -663,7 +740,7 @@ export class YaverFeedback {
663
740
  * Returns true when a new URL was adopted.
664
741
  */
665
742
  static async reconnect(): Promise<boolean> {
666
- if (!config || !enabled) return false;
743
+ if (!config || (!enabled && !dogfoodOnboarding)) return false;
667
744
  if (!config.authToken || !config.convexUrl) return false;
668
745
  try {
669
746
  const result = await YaverDiscovery.refreshFromConvex({
@@ -727,6 +804,7 @@ export class YaverFeedback {
727
804
  if (config.autoStartBlackBox !== false && !BlackBox.isStreaming) {
728
805
  YaverFeedback.scheduleBlackBoxAutoStart();
729
806
  }
807
+ await YaverFeedback.syncDogfoodAppShortcut().catch(() => false);
730
808
  }
731
809
 
732
810
  /** Returns true once the SDK has a session token it can use. */
@@ -807,17 +885,181 @@ export class YaverFeedback {
807
885
  DeviceEventEmitter.emit('yaverFeedback:startMachinePicker');
808
886
  }
809
887
 
810
- /** Begin the reusable host-app Dogfood wizard. Owner OAuth and machine
811
- * selection intentionally happen before installation enrollment/runtime
812
- * controls because starting builds or runners is owner-level authority. */
813
- static beginDogfoodOnboarding(options: DogfoodOnboardingOptions): void {
888
+ /** Configure Dogfood once during host init. Hosts still decide whether and
889
+ * where to render an affordance; `openDogfood()` owns all flow mechanics. */
890
+ static configureDogfood(options: DogfoodOnboardingOptions): void {
814
891
  dogfoodOnboarding = options;
815
- if (!config) YaverFeedback.init({ autoLogin: true } as FeedbackConfig);
816
- if (!YaverFeedback.isAuthed()) {
892
+ }
893
+
894
+ /**
895
+ * Open Dogfood using config.dogfood + the normal app identity. Cached OAuth,
896
+ * machine, runner and model choices are reused. The corresponding picker is
897
+ * shown only when a required choice is missing.
898
+ */
899
+ static async openDogfood(overrides?: Partial<DogfoodOnboardingOptions>): Promise<DogfoodFlowState> {
900
+ if (!config && overrides?.appId) {
901
+ return YaverFeedback.beginDogfoodOnboarding(overrides as DogfoodOnboardingOptions);
902
+ }
903
+ const configured = config?.dogfood;
904
+ const appId = overrides?.appId || configured?.appId || config?.bundleId;
905
+ if (!appId) {
906
+ const state: DogfoodFlowState = { phase: 'error', error: 'Dogfood requires an appId or FeedbackConfig.bundleId.' };
907
+ publishDogfoodFlow(state);
908
+ return state;
909
+ }
910
+ dogfoodOnboarding = {
911
+ appId,
912
+ label: overrides?.label || configured?.label || config?.projectName || appId,
913
+ projectName: overrides?.projectName || configured?.projectName || config?.projectName,
914
+ framework: overrides?.framework || configured?.framework,
915
+ backendUrl: overrides?.backendUrl || configured?.backendUrl,
916
+ secureStore: overrides?.secureStore,
917
+ };
918
+ if (configured?.canShow) {
919
+ // This hook is presentation policy, but it still receives the complete
920
+ // backend-authoritative snapshot. Passing an owner-only approximation
921
+ // here made legitimate approved testers fail custom `access.authorized`
922
+ // gates even though their exact phone key was active.
923
+ const access = await YaverFeedback.getDogfoodAccess();
924
+ try {
925
+ if (!(await configured.canShow(access))) {
926
+ const state: DogfoodFlowState = { phase: 'denied', appId };
927
+ publishDogfoodFlow(state);
928
+ return state;
929
+ }
930
+ } catch (cause) {
931
+ const state: DogfoodFlowState = {
932
+ phase: 'error',
933
+ appId,
934
+ error: cause instanceof Error ? cause.message : String(cause),
935
+ };
936
+ publishDogfoodFlow(state);
937
+ return state;
938
+ }
939
+ }
940
+ return YaverFeedback.continueDogfoodOnboarding();
941
+ }
942
+
943
+ /** Resolve the host-facing ACL snapshot without granting authority. This is
944
+ * the one endpoint custom Settings screens need for visibility/status UI. */
945
+ static async getDogfoodAccess(): Promise<DogfoodAccessSnapshot> {
946
+ const configured = config?.dogfood;
947
+ const appId = dogfoodOnboarding?.appId || configured?.appId || config?.bundleId;
948
+ if (!appId) throw new Error('Dogfood requires an appId or FeedbackConfig.bundleId.');
949
+ let installationId: string | undefined;
950
+ let deviceState: DogfoodAccessSnapshot['deviceState'] = 'unknown';
951
+ try {
952
+ const device = new YaverDeviceDogfood({
953
+ appId,
954
+ label: dogfoodOnboarding?.label || configured?.label || config?.projectName,
955
+ backendUrl: dogfoodOnboarding?.backendUrl || configured?.backendUrl,
956
+ secureStore: dogfoodOnboarding?.secureStore,
957
+ });
958
+ installationId = (await device.enrollmentInfo()).installationId;
959
+ deviceState = await device.status();
960
+ } catch {
961
+ // UI status must degrade to unknown; openDogfood still offers owner OAuth.
962
+ }
963
+ const token = config?.authToken || await getToken();
964
+ const account = token
965
+ ? await getDogfoodAccountAccess(appId, token, installationId)
966
+ : { authenticated: false, ownerAuthorized: false, installationAuthorized: false };
967
+ const yaverAuthenticated = account.authenticated;
968
+ const ownerAuthorized = account.ownerAuthorized;
969
+ return {
970
+ appId,
971
+ yaverAuthenticated,
972
+ ownerAuthorized,
973
+ installationId,
974
+ deviceState,
975
+ authorized: yaverAuthenticated && account.installationAuthorized && deviceState === 'active',
976
+ };
977
+ }
978
+
979
+ /** Add/remove the platform Home Screen shortcut from backend-authoritative
980
+ * owner/device ACL state. Static plist shortcuts are intentionally avoided:
981
+ * an unauthorized install must never advertise a hidden developer action. */
982
+ static async syncDogfoodAppShortcut(): Promise<boolean> {
983
+ const shortcut = config?.dogfood?.appShortcut;
984
+ const native = (NativeModules as any)?.YaverHotReload;
985
+ if (!shortcut || typeof native?.setDogfoodShortcut !== 'function') return false;
986
+ const access = await YaverFeedback.getDogfoodAccess();
987
+ let visible = access.authorized;
988
+ if (visible && config?.dogfood?.canShow) {
989
+ try {
990
+ visible = await config.dogfood.canShow(access);
991
+ } catch {
992
+ visible = false;
993
+ }
994
+ }
995
+ const label = typeof shortcut === 'object' && shortcut.label
996
+ ? shortcut.label
997
+ : `Dogfood ${config?.dogfood?.label || config?.projectName || ''}`.trim();
998
+ // Product contract: both a valid full Yaver account AND this phone's
999
+ // backend-approved app installation are required. Neither factor alone
1000
+ // advertises the developer surface.
1001
+ await native.setDogfoodShortcut(visible, label || 'Dogfood');
1002
+ return visible;
1003
+ }
1004
+
1005
+ /** Backwards-compatible entry point for existing integrations. */
1006
+ static async beginDogfoodOnboarding(options: DogfoodOnboardingOptions): Promise<DogfoodFlowState> {
1007
+ YaverFeedback.configureDogfood(options);
1008
+ if (!config) {
1009
+ YaverFeedback.init({
1010
+ autoLogin: true,
1011
+ enabled: true,
1012
+ projectName: options.projectName || options.label,
1013
+ bundleId: options.appId,
1014
+ } as FeedbackConfig);
1015
+ }
1016
+ return YaverFeedback.continueDogfoodOnboarding();
1017
+ }
1018
+
1019
+ /** Continue after OAuth or machine selection. Public so custom host UI can
1020
+ * hand control back without recreating the SDK state machine. */
1021
+ static async continueDogfoodOnboarding(): Promise<DogfoodFlowState> {
1022
+ const appId = dogfoodOnboarding?.appId;
1023
+ if (!dogfoodOnboarding || !appId) {
1024
+ const state: DogfoodFlowState = { phase: 'error', error: 'Dogfood is not configured.' };
1025
+ publishDogfoodFlow(state);
1026
+ return state;
1027
+ }
1028
+ await YaverFeedback.hydrateSession();
1029
+ const token = config?.authToken || await getToken();
1030
+ const account = token
1031
+ ? await getDogfoodAccountAccess(appId, token)
1032
+ : { authenticated: false, ownerAuthorized: false, installationAuthorized: false };
1033
+ // A device key is a second factor for this installation, never a
1034
+ // replacement for a real Yaver account. Narrow installation sessions and
1035
+ // stale/invalid cached tokens both return authenticated=false here.
1036
+ if (!account.authenticated) {
1037
+ const state: DogfoodFlowState = { phase: 'auth-required', appId };
1038
+ publishDogfoodFlow(state);
817
1039
  YaverFeedback.showLogin();
818
- return;
1040
+ return state;
1041
+ }
1042
+ if (!config?.preferredDeviceId) {
1043
+ const state: DogfoodFlowState = { phase: 'machine-required', appId };
1044
+ publishDogfoodFlow(state);
1045
+ YaverFeedback.showMachinePicker();
1046
+ return state;
819
1047
  }
820
- YaverFeedback.showMachinePicker();
1048
+ const state: DogfoodFlowState = { phase: 'opening', appId };
1049
+ publishDogfoodFlow(state);
1050
+ const { DeviceEventEmitter } = require('react-native');
1051
+ DeviceEventEmitter.emit('yaverFeedback:startReport');
1052
+ return state;
1053
+ }
1054
+
1055
+ static getDogfoodFlowState(): DogfoodFlowState {
1056
+ return dogfoodFlowState;
1057
+ }
1058
+
1059
+ static onDogfoodFlowState(listener: (state: DogfoodFlowState) => void): () => void {
1060
+ dogfoodFlowListeners.add(listener);
1061
+ listener(dogfoodFlowState);
1062
+ return () => dogfoodFlowListeners.delete(listener);
821
1063
  }
822
1064
 
823
1065
  static getDogfoodOnboarding(): DogfoodOnboardingOptions | null {
@@ -826,6 +1068,7 @@ export class YaverFeedback {
826
1068
 
827
1069
  static clearDogfoodOnboarding(): void {
828
1070
  dogfoodOnboarding = null;
1071
+ publishDogfoodFlow({ phase: 'idle' });
829
1072
  }
830
1073
 
831
1074
  /**
@@ -847,6 +1090,7 @@ export class YaverFeedback {
847
1090
  p2pClient = null;
848
1091
  renderP2PClient = null;
849
1092
  p2pAuthToken = null;
1093
+ await import('./auth').then(({ saveSelectedDeviceId }) => saveSelectedDeviceId(deviceId));
850
1094
  await YaverFeedback.discoverAgent();
851
1095
  }
852
1096
 
@@ -917,6 +1161,7 @@ export class YaverFeedback {
917
1161
  p2pClient = null;
918
1162
  renderP2PClient = null;
919
1163
  p2pAuthToken = null;
1164
+ await YaverFeedback.syncDogfoodAppShortcut().catch(() => false);
920
1165
  }
921
1166
 
922
1167
  /**
@@ -1178,16 +1423,16 @@ export class YaverFeedback {
1178
1423
  return resolveSDKDogfood(config?.dogfood);
1179
1424
  }
1180
1425
 
1181
- /** One-call, account-free Dogfood bootstrap for third-party apps. On first
1182
- * launch it creates/proves the installation key and returns pending; after
1183
- * owner approval the same call obtains a short-lived scoped Yaver session
1184
- * and enables Dogfood UX without the host app implementing OAuth. */
1426
+ /** One-call account-bound Dogfood bootstrap for third-party apps. The host
1427
+ * app needs no auth backend of its own: SDK OAuth supplies the full Yaver
1428
+ * account, then this creates/proves the installation key. Owner approval
1429
+ * binds that account + appId + phone key before a scoped session is minted. */
1185
1430
  static async enableDeviceDogfood(options: DeviceDogfoodOptions): Promise<{
1186
1431
  status: DeviceDogfoodState;
1187
1432
  installationId: string;
1188
1433
  session: DeviceDogfoodSession | null;
1189
1434
  }> {
1190
- const client = new YaverDeviceDogfood(options);
1435
+ const client = new YaverDeviceDogfood({ ...options, authToken: options.authToken || config?.authToken });
1191
1436
  let status = await client.status();
1192
1437
  if (status === 'unregistered' || status === 'pending' || status === 'cancelled' || status === 'revoked' || status === 'superseded') {
1193
1438
  const enrolled = status === 'unregistered' || status === 'pending'
@@ -1200,20 +1445,22 @@ export class YaverFeedback {
1200
1445
  if (!config) YaverFeedback.init({ autoLogin: false } as FeedbackConfig);
1201
1446
  if (config) {
1202
1447
  config.dogfood = {
1448
+ ...config.dogfood,
1203
1449
  enabled: status === 'active' && !!session,
1204
1450
  appId: options.appId,
1205
1451
  installationId: info.installationId,
1206
1452
  installationStatus: status === 'active' && session ? 'active' : status === 'unregistered' ? 'pending' : status,
1207
1453
  label: options.label,
1208
1454
  };
1209
- // A normal Yaver OAuth session remains authoritative for the runtime
1210
- // wizard. Only account-free hosts adopt the narrow installation token.
1211
- if (session && !config.authToken) await YaverFeedback.setAuthToken(session.token);
1455
+ // A normal full Yaver OAuth session remains authoritative for the
1456
+ // runtime wizard. The narrow installation token is intentionally never
1457
+ // promoted into account auth.
1212
1458
  }
1213
1459
  try {
1214
1460
  const { DeviceEventEmitter } = require('react-native');
1215
1461
  DeviceEventEmitter.emit('yaverFeedback:dogfoodChanged', { active: !!session, status });
1216
1462
  } catch { /* noop */ }
1463
+ await YaverFeedback.syncDogfoodAppShortcut().catch(() => false);
1217
1464
  return { status, installationId: info.installationId, session };
1218
1465
  }
1219
1466
 
@@ -1768,6 +2015,11 @@ export class YaverFeedback {
1768
2015
  // init() stacked a second one on top.
1769
2016
  commandUnsubscribe?.();
1770
2017
  commandUnsubscribe = null;
2018
+ dogfoodShortcutAppStateSubscription?.remove();
2019
+ dogfoodShortcutAppStateSubscription = null;
2020
+ dogfoodActivationSubscription?.remove();
2021
+ dogfoodActivationSubscription = null;
2022
+ lastDogfoodActivationUrl = '';
1771
2023
  // Before `enabled = false` / `config = null` below, so an in-flight retry
1772
2024
  // can't fire against a torn-down config.
1773
2025
  YaverFeedback.cancelBlackBoxAutoStart();
@@ -0,0 +1,37 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const plugin = require('../../app.plugin.js') as {
5
+ __test: { patchDogfoodAppShortcut(contents: string): string };
6
+ };
7
+
8
+ describe('native Dogfood shortcut contract', () => {
9
+ it('patches cold and warm iOS shortcut delivery idempotently', () => {
10
+ const source = `
11
+ public class AppDelegate: ExpoAppDelegate {
12
+ public override func application(
13
+ _ application: UIApplication,
14
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
15
+ ) -> Bool {
16
+ setupYaverHotReload()
17
+ return true
18
+ }
19
+ }
20
+ `;
21
+ const once = plugin.__test.patchDogfoodAppShortcut(source);
22
+ expect(once).toContain('launchOptions?[.shortcutItem]');
23
+ expect(once).toContain('performActionFor shortcutItem');
24
+ expect(once).toContain('markDogfoodShortcutPending()');
25
+ expect(plugin.__test.patchDogfoodAppShortcut(once)).toBe(once);
26
+ });
27
+
28
+ it('uses dynamic native shortcuts on both platforms', () => {
29
+ const ios = readFileSync(join(__dirname, '../../ios/YaverHotReload.swift'), 'utf8');
30
+ const android = readFileSync(join(__dirname, '../../android/src/main/java/io/yaver/feedback/YaverHotReloadModule.java'), 'utf8');
31
+ expect(ios).toContain('UIApplication.shared.shortcutItems');
32
+ expect(ios).toContain('setDogfoodShortcut');
33
+ expect(android).toContain('ShortcutManager');
34
+ expect(android).toContain('addDynamicShortcuts');
35
+ expect(android).toContain('consumeDogfoodShortcut');
36
+ });
37
+ });
@@ -10,9 +10,6 @@ const mockReactNative = {
10
10
  };
11
11
  const mockExpoModule: { default: Record<string, unknown> } = { default: {} };
12
12
 
13
- jest.mock('react-native', () => mockReactNative);
14
- jest.mock('expo-constants', () => mockExpoModule, { virtual: true });
15
-
16
13
  /** Mirrors mobile/app.json in the Talos repo — the SDK's first external consumer. */
17
14
  const EXPO_CONFIG = {
18
15
  name: 'Talos',
@@ -29,8 +26,13 @@ function mockExpoConstants(expoConfig: unknown, extra: Record<string, unknown> =
29
26
  function loadResolve(): typeof import('../P2PClient').resolveReportIdentity {
30
27
  let resolve!: typeof import('../P2PClient').resolveReportIdentity;
31
28
  jest.isolateModules(() => {
29
+ // Configure the mocks inside the isolated registry. Top-level virtual
30
+ // mocks are sticky across Jest's worker registry and lost to the real
31
+ // expo-constants package after a clean npm ci, making this suite depend on
32
+ // file order. doMock is non-hoisted and binds these exact mutable objects
33
+ // immediately before P2PClient is evaluated.
32
34
  jest.doMock('react-native', () => mockReactNative);
33
- jest.doMock('expo-constants', () => mockExpoModule, { virtual: true });
35
+ jest.doMock('expo-constants', () => mockExpoModule);
34
36
  resolve = require('../P2PClient').resolveReportIdentity;
35
37
  });
36
38
  return resolve;
@@ -1,4 +1,4 @@
1
- import { DeviceEventEmitter } from 'react-native';
1
+ import { DeviceEventEmitter, NativeModules } from 'react-native';
2
2
  import { YaverFeedback } from '../YaverFeedback';
3
3
 
4
4
  // Mock react-native: DeviceEventEmitter for event dispatch + Platform so
@@ -9,6 +9,13 @@ jest.mock('react-native', () => ({
9
9
  addListener: jest.fn(() => ({ remove: jest.fn() })),
10
10
  },
11
11
  Platform: { OS: 'ios' },
12
+ NativeModules: {
13
+ YaverHotReload: {
14
+ setDogfoodShortcut: jest.fn(async () => true),
15
+ consumeDogfoodShortcut: jest.fn(async () => false),
16
+ },
17
+ },
18
+ AppState: { addEventListener: jest.fn(() => ({ remove: jest.fn() })) },
12
19
  }));
13
20
 
14
21
  // Mock Discovery
@@ -23,6 +30,12 @@ jest.mock('../auth', () => ({
23
30
  setStrictNativeAuth: jest.fn(),
24
31
  getToken: jest.fn(async () => null),
25
32
  getSelectedDeviceId: jest.fn(async () => null),
33
+ getDogfoodAccountAccess: jest.fn(async (_appId: string, token: string) => ({
34
+ authenticated: token === 'owner-token',
35
+ ownerAuthorized: token === 'owner-token',
36
+ installationAuthorized: token === 'owner-token',
37
+ })),
38
+ saveSelectedDeviceId: jest.fn(async () => {}),
26
39
  clearToken: jest.fn(async () => {}),
27
40
  clearSelectedDeviceId: jest.fn(async () => {}),
28
41
  listReachableDevices: jest.fn(async () => ({
@@ -48,22 +61,108 @@ beforeEach(() => {
48
61
  // YaverFeedback uses module-level variables (config, enabled, p2pClient).
49
62
  // We reset them by calling init with a known state or relying on isInitialized checks.
50
63
  // For a clean slate, we re-init with enabled=false then verify.
64
+ jest.restoreAllMocks();
51
65
  jest.clearAllMocks();
52
66
  });
53
67
 
54
68
  describe('YaverFeedback', () => {
55
69
  describe('Dogfood onboarding', () => {
56
- it('starts with Yaver OAuth when the host has no session', () => {
70
+ it('starts with Yaver OAuth when the host has no session', async () => {
57
71
  YaverFeedback.init({ enabled: true });
58
- YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app', label: 'Example' });
72
+ await YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app', label: 'Example' });
59
73
  expect(DeviceEventEmitter.emit).toHaveBeenCalledWith('yaverFeedback:startLogin');
60
74
  });
61
75
 
62
- it('asks for a machine after an existing OAuth session', () => {
76
+ it('asks for a machine after an existing OAuth session', async () => {
63
77
  YaverFeedback.init({ enabled: true, authToken: 'owner-token' });
64
- YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app', label: 'Example' });
78
+ await YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app', label: 'Example' });
65
79
  expect(DeviceEventEmitter.emit).toHaveBeenCalledWith('yaverFeedback:startMachinePicker');
66
80
  });
81
+
82
+ it('reuses the configured app identity and selected machine', async () => {
83
+ YaverFeedback.init({
84
+ enabled: true,
85
+ authToken: 'owner-token',
86
+ preferredDeviceId: 'device-1',
87
+ projectName: 'Example',
88
+ bundleId: 'io.example.app',
89
+ dogfood: { framework: 'expo' },
90
+ });
91
+ const state = await YaverFeedback.openDogfood();
92
+ expect(state).toEqual({ phase: 'opening', appId: 'io.example.app' });
93
+ expect(DeviceEventEmitter.emit).toHaveBeenCalledWith('yaverFeedback:startReport');
94
+ expect(DeviceEventEmitter.emit).not.toHaveBeenCalledWith('yaverFeedback:startMachinePicker');
95
+ });
96
+
97
+ it('publishes flow state for custom host UI', async () => {
98
+ YaverFeedback.init({ enabled: true });
99
+ const states: string[] = [];
100
+ const unsubscribe = YaverFeedback.onDogfoodFlowState((state) => states.push(state.phase));
101
+ await YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app' });
102
+ unsubscribe();
103
+ expect(states[states.length - 1]).toBe('auth-required');
104
+ });
105
+
106
+ it('lets a host ACL hide its affordance without opening auth UI', async () => {
107
+ YaverFeedback.init({
108
+ enabled: true,
109
+ bundleId: 'io.example.app',
110
+ dogfood: { canShow: () => false },
111
+ });
112
+ const state = await YaverFeedback.openDogfood();
113
+ expect(state).toEqual({ phase: 'denied', appId: 'io.example.app' });
114
+ expect(DeviceEventEmitter.emit).not.toHaveBeenCalledWith('yaverFeedback:startLogin');
115
+ });
116
+
117
+ it('adds the app shortcut only for a signed-in and approved phone', async () => {
118
+ YaverFeedback.init({ bundleId: 'io.example.app', dogfood: {} });
119
+ YaverFeedback.getConfig()!.dogfood!.appShortcut = { label: 'Dogfood Example' };
120
+ jest.spyOn(YaverFeedback, 'getDogfoodAccess').mockResolvedValueOnce({
121
+ appId: 'io.example.app',
122
+ yaverAuthenticated: true,
123
+ ownerAuthorized: false,
124
+ installationId: 'phone-1',
125
+ deviceState: 'active',
126
+ authorized: true,
127
+ });
128
+ await YaverFeedback.syncDogfoodAppShortcut();
129
+ expect((NativeModules as any).YaverHotReload.setDogfoodShortcut)
130
+ .toHaveBeenCalledWith(true, 'Dogfood Example');
131
+ });
132
+
133
+ it('removes the shortcut when the phone is approved but Yaver is signed out', async () => {
134
+ YaverFeedback.init({ bundleId: 'io.example.app', dogfood: {} });
135
+ YaverFeedback.getConfig()!.dogfood!.appShortcut = true;
136
+ jest.spyOn(YaverFeedback, 'getDogfoodAccess').mockResolvedValueOnce({
137
+ appId: 'io.example.app',
138
+ yaverAuthenticated: false,
139
+ ownerAuthorized: false,
140
+ installationId: 'phone-1',
141
+ deviceState: 'active',
142
+ authorized: false,
143
+ });
144
+ await YaverFeedback.syncDogfoodAppShortcut();
145
+ expect((NativeModules as any).YaverHotReload.setDogfoodShortcut)
146
+ .toHaveBeenCalledWith(false, 'Dogfood');
147
+ });
148
+
149
+ it('applies the host presentation ACL after backend device authorization', async () => {
150
+ YaverFeedback.init({
151
+ bundleId: 'io.example.app',
152
+ dogfood: { appShortcut: true, canShow: async () => false },
153
+ });
154
+ jest.spyOn(YaverFeedback, 'getDogfoodAccess').mockResolvedValueOnce({
155
+ appId: 'io.example.app',
156
+ yaverAuthenticated: true,
157
+ ownerAuthorized: false,
158
+ installationId: 'phone-1',
159
+ deviceState: 'active',
160
+ authorized: true,
161
+ });
162
+ await YaverFeedback.syncDogfoodAppShortcut();
163
+ expect((NativeModules as any).YaverHotReload.setDogfoodShortcut)
164
+ .toHaveBeenCalledWith(false, 'Dogfood');
165
+ });
67
166
  });
68
167
 
69
168
  describe('init()', () => {
@@ -23,7 +23,7 @@ describe('YaverDeviceDogfood', () => {
23
23
 
24
24
  test('keeps identity stable and proves possession rather than trusting the UUID', async () => {
25
25
  const store = new MemorySecureStore();
26
- const client = new YaverDeviceDogfood({ appId: 'io.example.test', secureStore: store, backendUrl: 'https://dogfood.test' });
26
+ const client = new YaverDeviceDogfood({ appId: 'io.example.test', authToken: 'full-yaver-token', secureStore: store, backendUrl: 'https://dogfood.test' });
27
27
  const first = await client.enrollmentInfo();
28
28
  const second = await client.enrollmentInfo();
29
29
  expect(second).toEqual(first);
@@ -45,7 +45,7 @@ describe('YaverDeviceDogfood', () => {
45
45
 
46
46
  test('re-register rotates key and installation while preserving only the logical slot', async () => {
47
47
  const store = new MemorySecureStore();
48
- const client = new YaverDeviceDogfood({ appId: 'io.example.test', secureStore: store, backendUrl: 'https://dogfood.test' });
48
+ const client = new YaverDeviceDogfood({ appId: 'io.example.test', authToken: 'full-yaver-token', secureStore: store, backendUrl: 'https://dogfood.test' });
49
49
  const before = await client.enrollmentInfo();
50
50
  jest.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
51
51
  if (String(input).endsWith('/dogfood/enroll/start')) return response({ status: 'pending', challenge: 'rotate-nonce' });
@@ -20,7 +20,7 @@ describe('resolveSDKDogfood', () => {
20
20
  });
21
21
  });
22
22
 
23
- it('supports a key-enrolled installation without an app account', () => {
23
+ it('supports an account-bound key-enrolled installation without an app backend', () => {
24
24
  expect(resolveSDKDogfood({ enabled: true, appId: 'io.example', installationStatus: 'active' }).code)
25
25
  .toBe('SDK_DOGFOOD_INSTALLATION_REQUIRED');
26
26
  expect(resolveSDKDogfood({ enabled: true, appId: 'io.example', installationId: 'install-1', installationStatus: 'pending' }).code)