yaver-feedback-react-native 0.9.3 → 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 (43) 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/ShakeDetector.js +31 -17
  7. package/dist/YaverFeedback.d.ts +34 -9
  8. package/dist/YaverFeedback.js +304 -20
  9. package/dist/__tests__/NativeDogfoodShortcut.test.d.ts +1 -0
  10. package/dist/__tests__/NativeDogfoodShortcut.test.js +34 -0
  11. package/dist/__tests__/ReportIdentity.test.js +6 -3
  12. package/dist/__tests__/ShakeDetectorWeb.test.d.ts +1 -0
  13. package/dist/__tests__/ShakeDetectorWeb.test.js +27 -0
  14. package/dist/__tests__/YaverFeedback.test.js +97 -4
  15. package/dist/__tests__/deviceDogfood.test.js +2 -2
  16. package/dist/__tests__/dogfoodPolicy.test.js +1 -1
  17. package/dist/auth.d.ts +7 -0
  18. package/dist/auth.js +28 -0
  19. package/dist/deviceDogfood.d.ts +6 -2
  20. package/dist/deviceDogfood.js +6 -3
  21. package/dist/dogfoodPolicy.d.ts +32 -1
  22. package/dist/index.d.ts +4 -4
  23. package/dist/index.js +5 -2
  24. package/dist/preferences.d.ts +2 -0
  25. package/dist/preferences.js +24 -0
  26. package/ios/YaverHotReload.m +8 -0
  27. package/ios/YaverHotReload.swift +37 -0
  28. package/package.json +2 -2
  29. package/src/AuthOverlay.tsx +11 -2
  30. package/src/FeedbackModal.tsx +98 -6
  31. package/src/ShakeDetector.ts +31 -18
  32. package/src/YaverFeedback.ts +271 -19
  33. package/src/__tests__/NativeDogfoodShortcut.test.ts +37 -0
  34. package/src/__tests__/ReportIdentity.test.ts +6 -4
  35. package/src/__tests__/ShakeDetectorWeb.test.ts +30 -0
  36. package/src/__tests__/YaverFeedback.test.ts +104 -5
  37. package/src/__tests__/deviceDogfood.test.ts +2 -2
  38. package/src/__tests__/dogfoodPolicy.test.ts +1 -1
  39. package/src/auth.ts +29 -0
  40. package/src/deviceDogfood.ts +8 -3
  41. package/src/dogfoodPolicy.ts +32 -1
  42. package/src/index.ts +5 -2
  43. package/src/preferences.ts +20 -0
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.YaverFeedback = void 0;
4
37
  const react_native_1 = require("react-native");
@@ -155,6 +188,60 @@ let autoStartTimer = null;
155
188
  let reportLaunchInFlight = false;
156
189
  let crashReportInFlight = false;
157
190
  let dogfoodOnboarding = null;
191
+ let dogfoodFlowState = { phase: 'idle' };
192
+ const dogfoodFlowListeners = new Set();
193
+ let dogfoodShortcutAppStateSubscription = null;
194
+ let dogfoodActivationSubscription = null;
195
+ let dogfoodShortcutLaunchInFlight = false;
196
+ let lastDogfoodActivationUrl = '';
197
+ function publishDogfoodFlow(state) {
198
+ dogfoodFlowState = state;
199
+ try {
200
+ config?.dogfood?.onStateChange?.(state);
201
+ }
202
+ catch { /* host callback */ }
203
+ dogfoodFlowListeners.forEach((listener) => {
204
+ try {
205
+ listener(state);
206
+ }
207
+ catch { /* host callbacks never break the SDK */ }
208
+ });
209
+ }
210
+ async function consumeNativeDogfoodShortcut() {
211
+ if (dogfoodShortcutLaunchInFlight || !config?.dogfood?.appShortcut)
212
+ return;
213
+ const native = react_native_1.NativeModules?.YaverHotReload;
214
+ if (typeof native?.consumeDogfoodShortcut !== 'function')
215
+ return;
216
+ try {
217
+ if (!(await native.consumeDogfoodShortcut()))
218
+ return;
219
+ dogfoodShortcutLaunchInFlight = true;
220
+ // Let FeedbackModal/AuthOverlay effects mount after a cold launch.
221
+ const timer = setTimeout(() => {
222
+ void YaverFeedback.openDogfood().finally(() => { dogfoodShortcutLaunchInFlight = false; });
223
+ }, 350);
224
+ unrefTimer(timer);
225
+ }
226
+ catch {
227
+ dogfoodShortcutLaunchInFlight = false;
228
+ }
229
+ }
230
+ function handleDogfoodActivationUrl(url) {
231
+ if (!url || url === lastDogfoodActivationUrl || !config?.dogfood)
232
+ return;
233
+ try {
234
+ const parsed = new URL(url);
235
+ if (!parsed.protocol.startsWith('yaver-dogfood-') || parsed.hostname !== 'activate')
236
+ return;
237
+ }
238
+ catch {
239
+ return;
240
+ }
241
+ lastDogfoodActivationUrl = url;
242
+ const timer = setTimeout(() => { void YaverFeedback.openDogfood(); }, 350);
243
+ unrefTimer(timer);
244
+ }
158
245
  /** Resolve the user's relay password by validating their auth token
159
246
  * against Convex. Used whenever we (re)build the P2PClient so a
160
247
  * relay-routed agentUrl carries a valid X-Relay-Password — without
@@ -446,6 +533,32 @@ class YaverFeedback {
446
533
  if (config.autoLogin !== false && enabled) {
447
534
  void YaverFeedback.hydrateSession();
448
535
  }
536
+ if (config.dogfood?.appShortcut) {
537
+ void YaverFeedback.syncDogfoodAppShortcut();
538
+ void consumeNativeDogfoodShortcut();
539
+ try {
540
+ const { AppState } = require('react-native');
541
+ dogfoodShortcutAppStateSubscription?.remove();
542
+ dogfoodShortcutAppStateSubscription = AppState.addEventListener('change', (state) => {
543
+ if (state === 'active') {
544
+ void YaverFeedback.syncDogfoodAppShortcut();
545
+ void consumeNativeDogfoodShortcut();
546
+ }
547
+ });
548
+ }
549
+ catch { /* native shortcut unavailable on web/test runtimes */ }
550
+ }
551
+ if (config.dogfood) {
552
+ try {
553
+ const { Linking } = require('react-native');
554
+ dogfoodActivationSubscription?.remove();
555
+ dogfoodActivationSubscription = Linking.addEventListener('url', ({ url }) => {
556
+ handleDogfoodActivationUrl(url);
557
+ });
558
+ void Linking.getInitialURL().then(handleDogfoodActivationUrl).catch(() => { });
559
+ }
560
+ catch { /* linking unavailable in non-native test runtimes */ }
561
+ }
449
562
  // Create P2P client if we have a URL
450
563
  if (config.agentUrl) {
451
564
  p2pAuthToken = config.authToken ?? null;
@@ -640,7 +753,7 @@ class YaverFeedback {
640
753
  * Sets config.agentUrl and creates P2PClient on success.
641
754
  */
642
755
  static async discoverAgent() {
643
- if (!config || !enabled)
756
+ if (!config || (!enabled && !dogfoodOnboarding))
644
757
  return;
645
758
  if (config.agentUrl)
646
759
  return; // already have a URL
@@ -671,7 +784,7 @@ class YaverFeedback {
671
784
  * Returns true when a new URL was adopted.
672
785
  */
673
786
  static async reconnect() {
674
- if (!config || !enabled)
787
+ if (!config || (!enabled && !dogfoodOnboarding))
675
788
  return false;
676
789
  if (!config.authToken || !config.convexUrl)
677
790
  return false;
@@ -741,6 +854,7 @@ class YaverFeedback {
741
854
  if (config.autoStartBlackBox !== false && !BlackBox_1.BlackBox.isStreaming) {
742
855
  YaverFeedback.scheduleBlackBoxAutoStart();
743
856
  }
857
+ await YaverFeedback.syncDogfoodAppShortcut().catch(() => false);
744
858
  }
745
859
  /** Returns true once the SDK has a session token it can use. */
746
860
  static isAuthed() {
@@ -812,24 +926,186 @@ class YaverFeedback {
812
926
  const { DeviceEventEmitter } = require('react-native');
813
927
  DeviceEventEmitter.emit('yaverFeedback:startMachinePicker');
814
928
  }
815
- /** Begin the reusable host-app Dogfood wizard. Owner OAuth and machine
816
- * selection intentionally happen before installation enrollment/runtime
817
- * controls because starting builds or runners is owner-level authority. */
818
- static beginDogfoodOnboarding(options) {
929
+ /** Configure Dogfood once during host init. Hosts still decide whether and
930
+ * where to render an affordance; `openDogfood()` owns all flow mechanics. */
931
+ static configureDogfood(options) {
819
932
  dogfoodOnboarding = options;
820
- if (!config)
821
- YaverFeedback.init({ autoLogin: true });
822
- if (!YaverFeedback.isAuthed()) {
933
+ }
934
+ /**
935
+ * Open Dogfood using config.dogfood + the normal app identity. Cached OAuth,
936
+ * machine, runner and model choices are reused. The corresponding picker is
937
+ * shown only when a required choice is missing.
938
+ */
939
+ static async openDogfood(overrides) {
940
+ if (!config && overrides?.appId) {
941
+ return YaverFeedback.beginDogfoodOnboarding(overrides);
942
+ }
943
+ const configured = config?.dogfood;
944
+ const appId = overrides?.appId || configured?.appId || config?.bundleId;
945
+ if (!appId) {
946
+ const state = { phase: 'error', error: 'Dogfood requires an appId or FeedbackConfig.bundleId.' };
947
+ publishDogfoodFlow(state);
948
+ return state;
949
+ }
950
+ dogfoodOnboarding = {
951
+ appId,
952
+ label: overrides?.label || configured?.label || config?.projectName || appId,
953
+ projectName: overrides?.projectName || configured?.projectName || config?.projectName,
954
+ framework: overrides?.framework || configured?.framework,
955
+ backendUrl: overrides?.backendUrl || configured?.backendUrl,
956
+ secureStore: overrides?.secureStore,
957
+ };
958
+ if (configured?.canShow) {
959
+ // This hook is presentation policy, but it still receives the complete
960
+ // backend-authoritative snapshot. Passing an owner-only approximation
961
+ // here made legitimate approved testers fail custom `access.authorized`
962
+ // gates even though their exact phone key was active.
963
+ const access = await YaverFeedback.getDogfoodAccess();
964
+ try {
965
+ if (!(await configured.canShow(access))) {
966
+ const state = { phase: 'denied', appId };
967
+ publishDogfoodFlow(state);
968
+ return state;
969
+ }
970
+ }
971
+ catch (cause) {
972
+ const state = {
973
+ phase: 'error',
974
+ appId,
975
+ error: cause instanceof Error ? cause.message : String(cause),
976
+ };
977
+ publishDogfoodFlow(state);
978
+ return state;
979
+ }
980
+ }
981
+ return YaverFeedback.continueDogfoodOnboarding();
982
+ }
983
+ /** Resolve the host-facing ACL snapshot without granting authority. This is
984
+ * the one endpoint custom Settings screens need for visibility/status UI. */
985
+ static async getDogfoodAccess() {
986
+ const configured = config?.dogfood;
987
+ const appId = dogfoodOnboarding?.appId || configured?.appId || config?.bundleId;
988
+ if (!appId)
989
+ throw new Error('Dogfood requires an appId or FeedbackConfig.bundleId.');
990
+ let installationId;
991
+ let deviceState = 'unknown';
992
+ try {
993
+ const device = new deviceDogfood_1.YaverDeviceDogfood({
994
+ appId,
995
+ label: dogfoodOnboarding?.label || configured?.label || config?.projectName,
996
+ backendUrl: dogfoodOnboarding?.backendUrl || configured?.backendUrl,
997
+ secureStore: dogfoodOnboarding?.secureStore,
998
+ });
999
+ installationId = (await device.enrollmentInfo()).installationId;
1000
+ deviceState = await device.status();
1001
+ }
1002
+ catch {
1003
+ // UI status must degrade to unknown; openDogfood still offers owner OAuth.
1004
+ }
1005
+ const token = config?.authToken || await (0, auth_1.getToken)();
1006
+ const account = token
1007
+ ? await (0, auth_1.getDogfoodAccountAccess)(appId, token, installationId)
1008
+ : { authenticated: false, ownerAuthorized: false, installationAuthorized: false };
1009
+ const yaverAuthenticated = account.authenticated;
1010
+ const ownerAuthorized = account.ownerAuthorized;
1011
+ return {
1012
+ appId,
1013
+ yaverAuthenticated,
1014
+ ownerAuthorized,
1015
+ installationId,
1016
+ deviceState,
1017
+ authorized: yaverAuthenticated && account.installationAuthorized && deviceState === 'active',
1018
+ };
1019
+ }
1020
+ /** Add/remove the platform Home Screen shortcut from backend-authoritative
1021
+ * owner/device ACL state. Static plist shortcuts are intentionally avoided:
1022
+ * an unauthorized install must never advertise a hidden developer action. */
1023
+ static async syncDogfoodAppShortcut() {
1024
+ const shortcut = config?.dogfood?.appShortcut;
1025
+ const native = react_native_1.NativeModules?.YaverHotReload;
1026
+ if (!shortcut || typeof native?.setDogfoodShortcut !== 'function')
1027
+ return false;
1028
+ const access = await YaverFeedback.getDogfoodAccess();
1029
+ let visible = access.authorized;
1030
+ if (visible && config?.dogfood?.canShow) {
1031
+ try {
1032
+ visible = await config.dogfood.canShow(access);
1033
+ }
1034
+ catch {
1035
+ visible = false;
1036
+ }
1037
+ }
1038
+ const label = typeof shortcut === 'object' && shortcut.label
1039
+ ? shortcut.label
1040
+ : `Dogfood ${config?.dogfood?.label || config?.projectName || ''}`.trim();
1041
+ // Product contract: both a valid full Yaver account AND this phone's
1042
+ // backend-approved app installation are required. Neither factor alone
1043
+ // advertises the developer surface.
1044
+ await native.setDogfoodShortcut(visible, label || 'Dogfood');
1045
+ return visible;
1046
+ }
1047
+ /** Backwards-compatible entry point for existing integrations. */
1048
+ static async beginDogfoodOnboarding(options) {
1049
+ YaverFeedback.configureDogfood(options);
1050
+ if (!config) {
1051
+ YaverFeedback.init({
1052
+ autoLogin: true,
1053
+ enabled: true,
1054
+ projectName: options.projectName || options.label,
1055
+ bundleId: options.appId,
1056
+ });
1057
+ }
1058
+ return YaverFeedback.continueDogfoodOnboarding();
1059
+ }
1060
+ /** Continue after OAuth or machine selection. Public so custom host UI can
1061
+ * hand control back without recreating the SDK state machine. */
1062
+ static async continueDogfoodOnboarding() {
1063
+ const appId = dogfoodOnboarding?.appId;
1064
+ if (!dogfoodOnboarding || !appId) {
1065
+ const state = { phase: 'error', error: 'Dogfood is not configured.' };
1066
+ publishDogfoodFlow(state);
1067
+ return state;
1068
+ }
1069
+ await YaverFeedback.hydrateSession();
1070
+ const token = config?.authToken || await (0, auth_1.getToken)();
1071
+ const account = token
1072
+ ? await (0, auth_1.getDogfoodAccountAccess)(appId, token)
1073
+ : { authenticated: false, ownerAuthorized: false, installationAuthorized: false };
1074
+ // A device key is a second factor for this installation, never a
1075
+ // replacement for a real Yaver account. Narrow installation sessions and
1076
+ // stale/invalid cached tokens both return authenticated=false here.
1077
+ if (!account.authenticated) {
1078
+ const state = { phase: 'auth-required', appId };
1079
+ publishDogfoodFlow(state);
823
1080
  YaverFeedback.showLogin();
824
- return;
1081
+ return state;
1082
+ }
1083
+ if (!config?.preferredDeviceId) {
1084
+ const state = { phase: 'machine-required', appId };
1085
+ publishDogfoodFlow(state);
1086
+ YaverFeedback.showMachinePicker();
1087
+ return state;
825
1088
  }
826
- YaverFeedback.showMachinePicker();
1089
+ const state = { phase: 'opening', appId };
1090
+ publishDogfoodFlow(state);
1091
+ const { DeviceEventEmitter } = require('react-native');
1092
+ DeviceEventEmitter.emit('yaverFeedback:startReport');
1093
+ return state;
1094
+ }
1095
+ static getDogfoodFlowState() {
1096
+ return dogfoodFlowState;
1097
+ }
1098
+ static onDogfoodFlowState(listener) {
1099
+ dogfoodFlowListeners.add(listener);
1100
+ listener(dogfoodFlowState);
1101
+ return () => dogfoodFlowListeners.delete(listener);
827
1102
  }
828
1103
  static getDogfoodOnboarding() {
829
1104
  return dogfoodOnboarding;
830
1105
  }
831
1106
  static clearDogfoodOnboarding() {
832
1107
  dogfoodOnboarding = null;
1108
+ publishDogfoodFlow({ phase: 'idle' });
833
1109
  }
834
1110
  /**
835
1111
  * Update the selected remote device. Resets the cached agent URL so the
@@ -853,6 +1129,7 @@ class YaverFeedback {
853
1129
  p2pClient = null;
854
1130
  renderP2PClient = null;
855
1131
  p2pAuthToken = null;
1132
+ await Promise.resolve().then(() => __importStar(require('./auth'))).then(({ saveSelectedDeviceId }) => saveSelectedDeviceId(deviceId));
856
1133
  await YaverFeedback.discoverAgent();
857
1134
  }
858
1135
  /** Resolve the currently selected remote machine from the authenticated device list. */
@@ -913,6 +1190,7 @@ class YaverFeedback {
913
1190
  p2pClient = null;
914
1191
  renderP2PClient = null;
915
1192
  p2pAuthToken = null;
1193
+ await YaverFeedback.syncDogfoodAppShortcut().catch(() => false);
916
1194
  }
917
1195
  /**
918
1196
  * Manually trigger the feedback collection flow.
@@ -1161,12 +1439,12 @@ class YaverFeedback {
1161
1439
  static getDogfoodStatus() {
1162
1440
  return (0, dogfoodPolicy_1.resolveSDKDogfood)(config?.dogfood);
1163
1441
  }
1164
- /** One-call, account-free Dogfood bootstrap for third-party apps. On first
1165
- * launch it creates/proves the installation key and returns pending; after
1166
- * owner approval the same call obtains a short-lived scoped Yaver session
1167
- * and enables Dogfood UX without the host app implementing OAuth. */
1442
+ /** One-call account-bound Dogfood bootstrap for third-party apps. The host
1443
+ * app needs no auth backend of its own: SDK OAuth supplies the full Yaver
1444
+ * account, then this creates/proves the installation key. Owner approval
1445
+ * binds that account + appId + phone key before a scoped session is minted. */
1168
1446
  static async enableDeviceDogfood(options) {
1169
- const client = new deviceDogfood_1.YaverDeviceDogfood(options);
1447
+ const client = new deviceDogfood_1.YaverDeviceDogfood({ ...options, authToken: options.authToken || config?.authToken });
1170
1448
  let status = await client.status();
1171
1449
  if (status === 'unregistered' || status === 'pending' || status === 'cancelled' || status === 'revoked' || status === 'superseded') {
1172
1450
  const enrolled = status === 'unregistered' || status === 'pending'
@@ -1180,22 +1458,23 @@ class YaverFeedback {
1180
1458
  YaverFeedback.init({ autoLogin: false });
1181
1459
  if (config) {
1182
1460
  config.dogfood = {
1461
+ ...config.dogfood,
1183
1462
  enabled: status === 'active' && !!session,
1184
1463
  appId: options.appId,
1185
1464
  installationId: info.installationId,
1186
1465
  installationStatus: status === 'active' && session ? 'active' : status === 'unregistered' ? 'pending' : status,
1187
1466
  label: options.label,
1188
1467
  };
1189
- // A normal Yaver OAuth session remains authoritative for the runtime
1190
- // wizard. Only account-free hosts adopt the narrow installation token.
1191
- if (session && !config.authToken)
1192
- await YaverFeedback.setAuthToken(session.token);
1468
+ // A normal full Yaver OAuth session remains authoritative for the
1469
+ // runtime wizard. The narrow installation token is intentionally never
1470
+ // promoted into account auth.
1193
1471
  }
1194
1472
  try {
1195
1473
  const { DeviceEventEmitter } = require('react-native');
1196
1474
  DeviceEventEmitter.emit('yaverFeedback:dogfoodChanged', { active: !!session, status });
1197
1475
  }
1198
1476
  catch { /* noop */ }
1477
+ await YaverFeedback.syncDogfoodAppShortcut().catch(() => false);
1199
1478
  return { status, installationId: info.installationId, session };
1200
1479
  }
1201
1480
  /** Confirmed by the Y badge/UI before this is called. Reverts this SDK to
@@ -1705,6 +1984,11 @@ class YaverFeedback {
1705
1984
  // init() stacked a second one on top.
1706
1985
  commandUnsubscribe?.();
1707
1986
  commandUnsubscribe = null;
1987
+ dogfoodShortcutAppStateSubscription?.remove();
1988
+ dogfoodShortcutAppStateSubscription = null;
1989
+ dogfoodActivationSubscription?.remove();
1990
+ dogfoodActivationSubscription = null;
1991
+ lastDogfoodActivationUrl = '';
1708
1992
  // Before `enabled = false` / `config = null` below, so an in-flight retry
1709
1993
  // can't fire against a torn-down config.
1710
1994
  YaverFeedback.cancelBlackBoxAutoStart();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const fs_1 = require("fs");
4
+ const path_1 = require("path");
5
+ const plugin = require('../../app.plugin.js');
6
+ describe('native Dogfood shortcut contract', () => {
7
+ it('patches cold and warm iOS shortcut delivery idempotently', () => {
8
+ const source = `
9
+ public class AppDelegate: ExpoAppDelegate {
10
+ public override func application(
11
+ _ application: UIApplication,
12
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
13
+ ) -> Bool {
14
+ setupYaverHotReload()
15
+ return true
16
+ }
17
+ }
18
+ `;
19
+ const once = plugin.__test.patchDogfoodAppShortcut(source);
20
+ expect(once).toContain('launchOptions?[.shortcutItem]');
21
+ expect(once).toContain('performActionFor shortcutItem');
22
+ expect(once).toContain('markDogfoodShortcutPending()');
23
+ expect(plugin.__test.patchDogfoodAppShortcut(once)).toBe(once);
24
+ });
25
+ it('uses dynamic native shortcuts on both platforms', () => {
26
+ const ios = (0, fs_1.readFileSync)((0, path_1.join)(__dirname, '../../ios/YaverHotReload.swift'), 'utf8');
27
+ const android = (0, fs_1.readFileSync)((0, path_1.join)(__dirname, '../../android/src/main/java/io/yaver/feedback/YaverHotReloadModule.java'), 'utf8');
28
+ expect(ios).toContain('UIApplication.shared.shortcutItems');
29
+ expect(ios).toContain('setDogfoodShortcut');
30
+ expect(android).toContain('ShortcutManager');
31
+ expect(android).toContain('addDynamicShortcuts');
32
+ expect(android).toContain('consumeDogfoodShortcut');
33
+ });
34
+ });
@@ -9,8 +9,6 @@ const mockReactNative = {
9
9
  NativeModules: {},
10
10
  };
11
11
  const mockExpoModule = { default: {} };
12
- jest.mock('react-native', () => mockReactNative);
13
- jest.mock('expo-constants', () => mockExpoModule, { virtual: true });
14
12
  /** Mirrors mobile/app.json in the Talos repo — the SDK's first external consumer. */
15
13
  const EXPO_CONFIG = {
16
14
  name: 'Talos',
@@ -25,8 +23,13 @@ function mockExpoConstants(expoConfig, extra = {}) {
25
23
  function loadResolve() {
26
24
  let resolve;
27
25
  jest.isolateModules(() => {
26
+ // Configure the mocks inside the isolated registry. Top-level virtual
27
+ // mocks are sticky across Jest's worker registry and lost to the real
28
+ // expo-constants package after a clean npm ci, making this suite depend on
29
+ // file order. doMock is non-hoisted and binds these exact mutable objects
30
+ // immediately before P2PClient is evaluated.
28
31
  jest.doMock('react-native', () => mockReactNative);
29
- jest.doMock('expo-constants', () => mockExpoModule, { virtual: true });
32
+ jest.doMock('expo-constants', () => mockExpoModule);
30
33
  resolve = require('../P2PClient').resolveReportIdentity;
31
34
  });
32
35
  return resolve;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ // Browsers cannot provide a native shake gesture. expo-sensors still exports
3
+ // an Accelerometer facade there, and older SDK builds attempted to subscribe
4
+ // to it, crashing the host React tree through NativeEventEmitter.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const sensorAddListener = jest.fn(() => ({ remove: jest.fn() }));
7
+ const devEventAddListener = jest.fn(() => ({ remove: jest.fn() }));
8
+ jest.mock('react-native', () => ({
9
+ Platform: { OS: 'web' },
10
+ NativeModules: {},
11
+ DeviceEventEmitter: { addListener: devEventAddListener },
12
+ }));
13
+ jest.mock('expo-sensors', () => ({
14
+ Accelerometer: {
15
+ setUpdateInterval: jest.fn(),
16
+ addListener: sensorAddListener,
17
+ },
18
+ }), { virtual: true });
19
+ const ShakeDetector_1 = require("../ShakeDetector");
20
+ it('keeps explicit SDK controls alive without touching the native sensor on web', () => {
21
+ const detector = new ShakeDetector_1.ShakeDetector();
22
+ expect(() => detector.start(jest.fn())).not.toThrow();
23
+ expect(sensorAddListener).not.toHaveBeenCalled();
24
+ // The ordinary DeviceEventEmitter lane remains harmless and removable.
25
+ expect(devEventAddListener).toHaveBeenCalledTimes(1);
26
+ expect(() => detector.stop()).not.toThrow();
27
+ });
@@ -10,6 +10,13 @@ jest.mock('react-native', () => ({
10
10
  addListener: jest.fn(() => ({ remove: jest.fn() })),
11
11
  },
12
12
  Platform: { OS: 'ios' },
13
+ NativeModules: {
14
+ YaverHotReload: {
15
+ setDogfoodShortcut: jest.fn(async () => true),
16
+ consumeDogfoodShortcut: jest.fn(async () => false),
17
+ },
18
+ },
19
+ AppState: { addEventListener: jest.fn(() => ({ remove: jest.fn() })) },
13
20
  }));
14
21
  // Mock Discovery
15
22
  jest.mock('../Discovery', () => ({
@@ -22,6 +29,12 @@ jest.mock('../auth', () => ({
22
29
  setStrictNativeAuth: jest.fn(),
23
30
  getToken: jest.fn(async () => null),
24
31
  getSelectedDeviceId: jest.fn(async () => null),
32
+ getDogfoodAccountAccess: jest.fn(async (_appId, token) => ({
33
+ authenticated: token === 'owner-token',
34
+ ownerAuthorized: token === 'owner-token',
35
+ installationAuthorized: token === 'owner-token',
36
+ })),
37
+ saveSelectedDeviceId: jest.fn(async () => { }),
25
38
  clearToken: jest.fn(async () => { }),
26
39
  clearSelectedDeviceId: jest.fn(async () => { }),
27
40
  listReachableDevices: jest.fn(async () => ({
@@ -46,20 +59,100 @@ beforeEach(() => {
46
59
  // YaverFeedback uses module-level variables (config, enabled, p2pClient).
47
60
  // We reset them by calling init with a known state or relying on isInitialized checks.
48
61
  // For a clean slate, we re-init with enabled=false then verify.
62
+ jest.restoreAllMocks();
49
63
  jest.clearAllMocks();
50
64
  });
51
65
  describe('YaverFeedback', () => {
52
66
  describe('Dogfood onboarding', () => {
53
- it('starts with Yaver OAuth when the host has no session', () => {
67
+ it('starts with Yaver OAuth when the host has no session', async () => {
54
68
  YaverFeedback_1.YaverFeedback.init({ enabled: true });
55
- YaverFeedback_1.YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app', label: 'Example' });
69
+ await YaverFeedback_1.YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app', label: 'Example' });
56
70
  expect(react_native_1.DeviceEventEmitter.emit).toHaveBeenCalledWith('yaverFeedback:startLogin');
57
71
  });
58
- it('asks for a machine after an existing OAuth session', () => {
72
+ it('asks for a machine after an existing OAuth session', async () => {
59
73
  YaverFeedback_1.YaverFeedback.init({ enabled: true, authToken: 'owner-token' });
60
- YaverFeedback_1.YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app', label: 'Example' });
74
+ await YaverFeedback_1.YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app', label: 'Example' });
61
75
  expect(react_native_1.DeviceEventEmitter.emit).toHaveBeenCalledWith('yaverFeedback:startMachinePicker');
62
76
  });
77
+ it('reuses the configured app identity and selected machine', async () => {
78
+ YaverFeedback_1.YaverFeedback.init({
79
+ enabled: true,
80
+ authToken: 'owner-token',
81
+ preferredDeviceId: 'device-1',
82
+ projectName: 'Example',
83
+ bundleId: 'io.example.app',
84
+ dogfood: { framework: 'expo' },
85
+ });
86
+ const state = await YaverFeedback_1.YaverFeedback.openDogfood();
87
+ expect(state).toEqual({ phase: 'opening', appId: 'io.example.app' });
88
+ expect(react_native_1.DeviceEventEmitter.emit).toHaveBeenCalledWith('yaverFeedback:startReport');
89
+ expect(react_native_1.DeviceEventEmitter.emit).not.toHaveBeenCalledWith('yaverFeedback:startMachinePicker');
90
+ });
91
+ it('publishes flow state for custom host UI', async () => {
92
+ YaverFeedback_1.YaverFeedback.init({ enabled: true });
93
+ const states = [];
94
+ const unsubscribe = YaverFeedback_1.YaverFeedback.onDogfoodFlowState((state) => states.push(state.phase));
95
+ await YaverFeedback_1.YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app' });
96
+ unsubscribe();
97
+ expect(states[states.length - 1]).toBe('auth-required');
98
+ });
99
+ it('lets a host ACL hide its affordance without opening auth UI', async () => {
100
+ YaverFeedback_1.YaverFeedback.init({
101
+ enabled: true,
102
+ bundleId: 'io.example.app',
103
+ dogfood: { canShow: () => false },
104
+ });
105
+ const state = await YaverFeedback_1.YaverFeedback.openDogfood();
106
+ expect(state).toEqual({ phase: 'denied', appId: 'io.example.app' });
107
+ expect(react_native_1.DeviceEventEmitter.emit).not.toHaveBeenCalledWith('yaverFeedback:startLogin');
108
+ });
109
+ it('adds the app shortcut only for a signed-in and approved phone', async () => {
110
+ YaverFeedback_1.YaverFeedback.init({ bundleId: 'io.example.app', dogfood: {} });
111
+ YaverFeedback_1.YaverFeedback.getConfig().dogfood.appShortcut = { label: 'Dogfood Example' };
112
+ jest.spyOn(YaverFeedback_1.YaverFeedback, 'getDogfoodAccess').mockResolvedValueOnce({
113
+ appId: 'io.example.app',
114
+ yaverAuthenticated: true,
115
+ ownerAuthorized: false,
116
+ installationId: 'phone-1',
117
+ deviceState: 'active',
118
+ authorized: true,
119
+ });
120
+ await YaverFeedback_1.YaverFeedback.syncDogfoodAppShortcut();
121
+ expect(react_native_1.NativeModules.YaverHotReload.setDogfoodShortcut)
122
+ .toHaveBeenCalledWith(true, 'Dogfood Example');
123
+ });
124
+ it('removes the shortcut when the phone is approved but Yaver is signed out', async () => {
125
+ YaverFeedback_1.YaverFeedback.init({ bundleId: 'io.example.app', dogfood: {} });
126
+ YaverFeedback_1.YaverFeedback.getConfig().dogfood.appShortcut = true;
127
+ jest.spyOn(YaverFeedback_1.YaverFeedback, 'getDogfoodAccess').mockResolvedValueOnce({
128
+ appId: 'io.example.app',
129
+ yaverAuthenticated: false,
130
+ ownerAuthorized: false,
131
+ installationId: 'phone-1',
132
+ deviceState: 'active',
133
+ authorized: false,
134
+ });
135
+ await YaverFeedback_1.YaverFeedback.syncDogfoodAppShortcut();
136
+ expect(react_native_1.NativeModules.YaverHotReload.setDogfoodShortcut)
137
+ .toHaveBeenCalledWith(false, 'Dogfood');
138
+ });
139
+ it('applies the host presentation ACL after backend device authorization', async () => {
140
+ YaverFeedback_1.YaverFeedback.init({
141
+ bundleId: 'io.example.app',
142
+ dogfood: { appShortcut: true, canShow: async () => false },
143
+ });
144
+ jest.spyOn(YaverFeedback_1.YaverFeedback, 'getDogfoodAccess').mockResolvedValueOnce({
145
+ appId: 'io.example.app',
146
+ yaverAuthenticated: true,
147
+ ownerAuthorized: false,
148
+ installationId: 'phone-1',
149
+ deviceState: 'active',
150
+ authorized: true,
151
+ });
152
+ await YaverFeedback_1.YaverFeedback.syncDogfoodAppShortcut();
153
+ expect(react_native_1.NativeModules.YaverHotReload.setDogfoodShortcut)
154
+ .toHaveBeenCalledWith(false, 'Dogfood');
155
+ });
63
156
  });
64
157
  describe('init()', () => {
65
158
  it('sets config correctly with defaults', () => {
@@ -26,7 +26,7 @@ describe('YaverDeviceDogfood', () => {
26
26
  afterEach(() => { jest.restoreAllMocks(); });
27
27
  test('keeps identity stable and proves possession rather than trusting the UUID', async () => {
28
28
  const store = new MemorySecureStore();
29
- const client = new deviceDogfood_1.YaverDeviceDogfood({ appId: 'io.example.test', secureStore: store, backendUrl: 'https://dogfood.test' });
29
+ const client = new deviceDogfood_1.YaverDeviceDogfood({ appId: 'io.example.test', authToken: 'full-yaver-token', secureStore: store, backendUrl: 'https://dogfood.test' });
30
30
  const first = await client.enrollmentInfo();
31
31
  const second = await client.enrollmentInfo();
32
32
  expect(second).toEqual(first);
@@ -49,7 +49,7 @@ describe('YaverDeviceDogfood', () => {
49
49
  });
50
50
  test('re-register rotates key and installation while preserving only the logical slot', async () => {
51
51
  const store = new MemorySecureStore();
52
- const client = new deviceDogfood_1.YaverDeviceDogfood({ appId: 'io.example.test', secureStore: store, backendUrl: 'https://dogfood.test' });
52
+ const client = new deviceDogfood_1.YaverDeviceDogfood({ appId: 'io.example.test', authToken: 'full-yaver-token', secureStore: store, backendUrl: 'https://dogfood.test' });
53
53
  const before = await client.enrollmentInfo();
54
54
  jest.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
55
55
  if (String(input).endsWith('/dogfood/enroll/start'))