yaver-feedback-react-native 0.9.0 → 0.9.3

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 (144) hide show
  1. package/README.md +102 -1
  2. package/app.plugin.js +126 -9
  3. package/dist/AuthOverlay.d.ts +1 -14
  4. package/dist/AuthOverlay.js +9 -62
  5. package/dist/BlackBox.js +9 -1
  6. package/dist/DeployPanel.js +65 -0
  7. package/dist/Discovery.js +13 -8
  8. package/dist/DogfoodRuntime.d.ts +124 -0
  9. package/dist/DogfoodRuntime.js +273 -0
  10. package/dist/FeedbackModal.js +505 -65
  11. package/dist/LoginScreen.d.ts +1 -5
  12. package/dist/LoginScreen.js +2 -6
  13. package/dist/MachinePickerScreen.d.ts +1 -3
  14. package/dist/MachinePickerScreen.js +6 -18
  15. package/dist/P2PClient.d.ts +178 -2
  16. package/dist/P2PClient.js +493 -3
  17. package/dist/P2PDogfoodDriver.d.ts +12 -0
  18. package/dist/P2PDogfoodDriver.js +118 -0
  19. package/dist/PairDeviceModal.d.ts +2 -3
  20. package/dist/ShakeDetector.js +2 -0
  21. package/dist/VibeChatScreen.d.ts +11 -1
  22. package/dist/VibeChatScreen.js +200 -41
  23. package/dist/YaverFeedback.d.ts +132 -0
  24. package/dist/YaverFeedback.js +633 -65
  25. package/dist/YaverModeBadge.d.ts +22 -0
  26. package/dist/YaverModeBadge.js +219 -0
  27. package/dist/__tests__/AuthDevices.test.js +1 -48
  28. package/dist/__tests__/BlackBox.relayPassword.test.d.ts +1 -0
  29. package/dist/__tests__/BlackBox.relayPassword.test.js +105 -0
  30. package/dist/__tests__/BlackBoxAutoStart.test.d.ts +1 -0
  31. package/dist/__tests__/BlackBoxAutoStart.test.js +91 -0
  32. package/dist/__tests__/BlackBoxAutoStartColdStart.test.d.ts +1 -0
  33. package/dist/__tests__/BlackBoxAutoStartColdStart.test.js +156 -0
  34. package/dist/__tests__/BrowserLaneIcon.test.d.ts +3 -0
  35. package/dist/__tests__/BrowserLaneIcon.test.js +80 -0
  36. package/dist/__tests__/DogfoodRuntime.test.d.ts +1 -0
  37. package/dist/__tests__/DogfoodRuntime.test.js +116 -0
  38. package/dist/__tests__/P2PClient.test.js +2 -2
  39. package/dist/__tests__/P2PDogfoodDriver.test.d.ts +1 -0
  40. package/dist/__tests__/P2PDogfoodDriver.test.js +64 -0
  41. package/dist/__tests__/ReportIdentity.test.d.ts +25 -0
  42. package/dist/__tests__/ReportIdentity.test.js +180 -0
  43. package/dist/__tests__/SDKToken.test.js +1 -1
  44. package/dist/__tests__/ShakeToggle.test.d.ts +1 -0
  45. package/dist/__tests__/ShakeToggle.test.js +121 -0
  46. package/dist/__tests__/YaverFeedback.test.js +13 -3
  47. package/dist/__tests__/deviceDogfood.test.d.ts +1 -0
  48. package/dist/__tests__/deviceDogfood.test.js +86 -0
  49. package/dist/__tests__/dogfoodPolicy.test.d.ts +1 -0
  50. package/dist/__tests__/dogfoodPolicy.test.js +33 -0
  51. package/dist/__tests__/pickTargetDevice.test.d.ts +1 -0
  52. package/dist/__tests__/pickTargetDevice.test.js +89 -0
  53. package/dist/__tests__/reloadActions.test.d.ts +1 -0
  54. package/dist/__tests__/reloadActions.test.js +129 -0
  55. package/dist/__tests__/reloadActionsParity.test.d.ts +1 -0
  56. package/dist/__tests__/reloadActionsParity.test.js +42 -0
  57. package/dist/__tests__/types.test.js +5 -5
  58. package/dist/_core/ansi.d.ts +117 -0
  59. package/dist/_core/ansi.js +468 -0
  60. package/dist/_core/ansi.test.d.ts +1 -0
  61. package/dist/_core/ansi.test.js +225 -0
  62. package/dist/_core/buildFeedbackPrompt.d.ts +4 -9
  63. package/dist/_core/buildFeedbackPrompt.js +18 -72
  64. package/dist/_core/constants.d.ts +19 -6
  65. package/dist/_core/constants.js +20 -7
  66. package/dist/_core/device.d.ts +1 -5
  67. package/dist/_core/device.js +5 -14
  68. package/dist/_core/endpoints.d.ts +0 -7
  69. package/dist/_core/endpoints.js +0 -7
  70. package/dist/_core/index.d.ts +4 -0
  71. package/dist/_core/index.js +4 -0
  72. package/dist/_core/remoteless.d.ts +44 -0
  73. package/dist/_core/remoteless.js +75 -0
  74. package/dist/_core/trace.d.ts +47 -0
  75. package/dist/_core/trace.js +38 -0
  76. package/dist/_core/trace.test.d.ts +1 -0
  77. package/dist/_core/trace.test.js +60 -0
  78. package/dist/auth.d.ts +3 -60
  79. package/dist/auth.js +6 -88
  80. package/dist/deviceDogfood.d.ts +53 -0
  81. package/dist/deviceDogfood.js +137 -0
  82. package/dist/dogfoodPolicy.d.ts +25 -0
  83. package/dist/dogfoodPolicy.js +24 -0
  84. package/dist/index.d.ts +17 -4
  85. package/dist/index.js +33 -7
  86. package/dist/reloadActions.d.ts +88 -0
  87. package/dist/reloadActions.js +200 -0
  88. package/dist/storeShots.d.ts +67 -0
  89. package/dist/storeShots.js +137 -0
  90. package/dist/types.d.ts +208 -8
  91. package/package.json +13 -4
  92. package/src/AuthOverlay.tsx +20 -106
  93. package/src/BlackBox.ts +10 -1
  94. package/src/DeployPanel.tsx +74 -0
  95. package/src/Discovery.ts +13 -8
  96. package/src/DogfoodRuntime.ts +373 -0
  97. package/src/FeedbackModal.tsx +619 -79
  98. package/src/LoginScreen.tsx +2 -22
  99. package/src/MachinePickerScreen.tsx +6 -21
  100. package/src/P2PClient.ts +580 -4
  101. package/src/P2PDogfoodDriver.ts +132 -0
  102. package/src/PairDeviceModal.tsx +2 -3
  103. package/src/ShakeDetector.ts +1 -0
  104. package/src/VibeChatScreen.tsx +232 -42
  105. package/src/YaverFeedback.ts +631 -68
  106. package/src/YaverModeBadge.tsx +234 -0
  107. package/src/__tests__/AuthDevices.test.ts +1 -52
  108. package/src/__tests__/BlackBox.relayPassword.test.ts +129 -0
  109. package/src/__tests__/BlackBoxAutoStart.test.ts +111 -0
  110. package/src/__tests__/BlackBoxAutoStartColdStart.test.ts +191 -0
  111. package/src/__tests__/BrowserLaneIcon.test.ts +85 -0
  112. package/src/__tests__/DogfoodRuntime.test.ts +135 -0
  113. package/src/__tests__/P2PClient.test.ts +2 -2
  114. package/src/__tests__/P2PDogfoodDriver.test.ts +80 -0
  115. package/src/__tests__/ReportIdentity.test.ts +212 -0
  116. package/src/__tests__/SDKToken.test.ts +1 -1
  117. package/src/__tests__/ShakeToggle.test.ts +153 -0
  118. package/src/__tests__/YaverFeedback.test.ts +15 -3
  119. package/src/__tests__/deviceDogfood.test.ts +77 -0
  120. package/src/__tests__/dogfoodPolicy.test.ts +34 -0
  121. package/src/__tests__/pickTargetDevice.test.ts +101 -0
  122. package/src/__tests__/reloadActions.test.ts +171 -0
  123. package/src/__tests__/reloadActionsParity.test.ts +49 -0
  124. package/src/__tests__/types.test.ts +5 -5
  125. package/src/_core/ansi.test.ts +250 -0
  126. package/src/_core/ansi.ts +475 -0
  127. package/src/_core/buildFeedbackPrompt.ts +18 -95
  128. package/src/_core/constants.ts +20 -6
  129. package/src/_core/device.ts +5 -16
  130. package/src/_core/endpoints.ts +0 -7
  131. package/src/_core/index.ts +4 -0
  132. package/src/_core/remoteless.ts +110 -0
  133. package/src/_core/trace.test.ts +58 -0
  134. package/src/_core/trace.ts +75 -0
  135. package/src/auth.ts +7 -156
  136. package/src/deviceDogfood.ts +171 -0
  137. package/src/dogfoodPolicy.ts +40 -0
  138. package/src/index.ts +61 -11
  139. package/src/reloadActions.ts +273 -0
  140. package/src/storeShots.ts +189 -0
  141. package/src/types.ts +209 -8
  142. package/dist/GuestOnboardingScreen.d.ts +0 -8
  143. package/dist/GuestOnboardingScreen.js +0 -282
  144. package/src/GuestOnboardingScreen.tsx +0 -307
@@ -0,0 +1,212 @@
1
+ // resolveAppIdentity() has always fed /vibing/execute and /dev/reload-app so
2
+ // the agent could route "vibe on THIS app" to the right repo, but nothing fed
3
+ // /feedback — reports carried no identity at all, so the agent's fix router
4
+ // had nothing to resolve and fell back to whatever directory it was sitting
5
+ // in. resolveReportIdentity() closes that gap for the feedback path.
6
+
7
+ const mockReactNative = {
8
+ Platform: { OS: 'ios' },
9
+ NativeModules: {},
10
+ };
11
+ const mockExpoModule: { default: Record<string, unknown> } = { default: {} };
12
+
13
+ jest.mock('react-native', () => mockReactNative);
14
+ jest.mock('expo-constants', () => mockExpoModule, { virtual: true });
15
+
16
+ /** Mirrors mobile/app.json in the Talos repo — the SDK's first external consumer. */
17
+ const EXPO_CONFIG = {
18
+ name: 'Talos',
19
+ slug: 'talos-mobile',
20
+ version: '1.9.157',
21
+ ios: { bundleIdentifier: 'works.talos.mobile', buildNumber: '427' },
22
+ android: { package: 'works.talos.mobile', versionCode: 423 },
23
+ };
24
+
25
+ function mockExpoConstants(expoConfig: unknown, extra: Record<string, unknown> = {}) {
26
+ mockExpoModule.default = { expoConfig, ...extra };
27
+ }
28
+
29
+ function loadResolve(): typeof import('../P2PClient').resolveReportIdentity {
30
+ let resolve!: typeof import('../P2PClient').resolveReportIdentity;
31
+ jest.isolateModules(() => {
32
+ jest.doMock('react-native', () => mockReactNative);
33
+ jest.doMock('expo-constants', () => mockExpoModule, { virtual: true });
34
+ resolve = require('../P2PClient').resolveReportIdentity;
35
+ });
36
+ return resolve;
37
+ }
38
+
39
+ beforeEach(() => {
40
+ // P2PClient is imported by many suites with different virtual RN/Expo
41
+ // modules. Re-evaluate it against this suite's mutable modules every time;
42
+ // an order-dependent green test is not an identity-routing guard.
43
+ jest.resetModules();
44
+ mockReactNative.Platform.OS = 'ios';
45
+ mockReactNative.NativeModules = {};
46
+ mockExpoModule.default = {};
47
+ });
48
+
49
+ describe('resolveReportIdentity', () => {
50
+ it('resolves app name and bundle id from the Expo config', () => {
51
+ mockExpoConstants(EXPO_CONFIG);
52
+ const resolve = loadResolve();
53
+
54
+ const identity = resolve();
55
+
56
+ // The agent's fix router reads DeviceInfo.AppName first.
57
+ expect(identity.appName).toBe('Talos');
58
+ // ...and resolves the project by bundle id, the only unambiguous key.
59
+ expect(identity.project?.bundleId).toBe('works.talos.mobile');
60
+ expect(identity.project?.projectName).toBe('Talos');
61
+ expect(identity.project?.appName).toBe('Talos');
62
+ expect(identity.project?.surface).toBe('mobile');
63
+ expect(identity.app.bundleId).toBe('works.talos.mobile');
64
+ });
65
+
66
+ it('never reports a project path', () => {
67
+ mockExpoConstants(EXPO_CONFIG);
68
+ const resolve = loadResolve();
69
+
70
+ // The agent ignores client-supplied paths on feedback reports (an
71
+ // untrusted guest could otherwise aim the fix task's CWD at ~/.ssh).
72
+ // Sending one would be misleading at best.
73
+ expect(resolve().project?.projectPath).toBeUndefined();
74
+ });
75
+
76
+ it('prefers the native runtime version over the manifest version', () => {
77
+ mockExpoConstants(EXPO_CONFIG, { nativeAppVersion: '1.9.158', nativeBuildVersion: '428' });
78
+ const resolve = loadResolve();
79
+
80
+ const identity = resolve();
81
+ // A stale manifest shouldn't misreport which build is actually running.
82
+ expect(identity.app.version).toBe('1.9.158');
83
+ expect(identity.app.buildNumber).toBe('428');
84
+ });
85
+
86
+ it('falls back to the manifest version when no native version is exposed', () => {
87
+ mockExpoConstants(EXPO_CONFIG);
88
+ const resolve = loadResolve();
89
+
90
+ const identity = resolve();
91
+ expect(identity.app.version).toBe('1.9.157');
92
+ expect(identity.app.buildNumber).toBe('427');
93
+ });
94
+
95
+ // Inside Yaver's container the ambient runtime IS Yaver. These are the
96
+ // cases where getting it wrong routes a fix task into yaver.io and edits
97
+ // the SDK instead of the app under test.
98
+ describe('inside the Yaver container (Hermes guest)', () => {
99
+ const YAVER_CONFIG = {
100
+ name: 'Yaver',
101
+ version: '1.99.306',
102
+ ios: { bundleIdentifier: 'io.yaver.mobile' },
103
+ android: { package: 'io.yaver.mobile' },
104
+ };
105
+
106
+ function mockContainer(yaverInfo: Record<string, unknown>) {
107
+ mockReactNative.NativeModules = { YaverInfo: { isYaver: true, ...yaverInfo } };
108
+ // The host's manifest — what expo-constants answers for a guest bundle.
109
+ mockExpoConstants(YAVER_CONFIG);
110
+ }
111
+
112
+ it("never reports the host's bundle id as the guest's", () => {
113
+ mockContainer({ inheritedGuestProjectName: 'talos / mobile' });
114
+ const resolve = loadResolve();
115
+
116
+ const identity = resolve();
117
+
118
+ // The agent routes on bundle id FIRST. Leaking io.yaver.mobile here
119
+ // would resolve to yaver.io and the project name would never be read.
120
+ expect(identity.project?.bundleId).toBeUndefined();
121
+ expect(identity.app.bundleId).toBeUndefined();
122
+ // Nor should the host's version masquerade as the guest's build.
123
+ expect(identity.app.version).toBeUndefined();
124
+ });
125
+
126
+ it('reports the guest project Yaver pinned', () => {
127
+ mockContainer({ inheritedGuestProjectName: 'talos / mobile' });
128
+ const resolve = loadResolve();
129
+
130
+ const identity = resolve();
131
+ expect(identity.appName).toBe('talos / mobile');
132
+ expect(identity.project?.projectName).toBe('talos / mobile');
133
+ });
134
+
135
+ it("lets the app's own declaration win over the pinned project", () => {
136
+ mockContainer({ inheritedGuestProjectName: 'something-else' });
137
+ const resolve = loadResolve();
138
+
139
+ // A bundle knows its own identity; nothing ambient should override it.
140
+ const identity = resolve({ projectName: 'Talos', bundleId: 'works.talos.mobile' });
141
+ expect(identity.appName).toBe('Talos');
142
+ expect(identity.project?.bundleId).toBe('works.talos.mobile');
143
+ });
144
+
145
+ it('reports nothing rather than something wrong when no project is pinned', () => {
146
+ mockContainer({});
147
+ const resolve = loadResolve();
148
+
149
+ // Better for the agent to reject/fall back than to confidently edit
150
+ // the wrong repo.
151
+ const identity = resolve();
152
+ expect(identity.project).toBeUndefined();
153
+ expect(identity.appName).toBeUndefined();
154
+ expect(identity.app).toEqual({});
155
+ });
156
+ });
157
+
158
+ it('lets an explicit declaration override the ambient identity', () => {
159
+ mockExpoConstants(EXPO_CONFIG);
160
+ const resolve = loadResolve();
161
+
162
+ const identity = resolve({ projectName: 'Override', bundleId: 'com.override.app' });
163
+ expect(identity.appName).toBe('Override');
164
+ expect(identity.project?.bundleId).toBe('com.override.app');
165
+ });
166
+
167
+ it('passes declared surfaces stacks and voice metadata through', () => {
168
+ mockExpoConstants(EXPO_CONFIG);
169
+ const resolve = loadResolve();
170
+
171
+ const identity = resolve({
172
+ projectName: 'Omni',
173
+ bundleId: 'io.example.omni',
174
+ surface: 'vision',
175
+ surfaces: ['mobile', 'web', 'watch', 'tv', 'car', 'vision'],
176
+ stack: 'react-native-expo',
177
+ stacks: ['react-native-expo', 'nextjs', 'yaver-xml'],
178
+ testSurfaces: ['rn-hermes', 'browser', 'visionos-simulator'],
179
+ feedbackSdk: 'yaver-feedback-react-native',
180
+ feedbackTransport: 'device-sdk',
181
+ voiceCapabilities: ['stt', 'tts', 'device-mic'],
182
+ sttProvider: 'deepgram',
183
+ ttsProvider: 'local',
184
+ });
185
+
186
+ expect(identity.project).toMatchObject({
187
+ surface: 'vision',
188
+ surfaces: ['mobile', 'web', 'watch', 'tv', 'car', 'vision'],
189
+ stack: 'react-native-expo',
190
+ stacks: ['react-native-expo', 'nextjs', 'yaver-xml'],
191
+ testSurfaces: ['rn-hermes', 'browser', 'visionos-simulator'],
192
+ feedbackSdk: 'yaver-feedback-react-native',
193
+ feedbackTransport: 'device-sdk',
194
+ voiceCapabilities: ['stt', 'tts', 'device-mic'],
195
+ sttProvider: 'deepgram',
196
+ ttsProvider: 'local',
197
+ });
198
+ expect(identity.project?.projectPath).toBeUndefined();
199
+ });
200
+
201
+ it('omits the project block when nothing identifies the app', () => {
202
+ // Bare RN with no expo-constants and no native modules. The report must
203
+ // still upload — the agent just resolves it by its own means.
204
+ mockExpoModule.default = {};
205
+ const resolve = loadResolve();
206
+
207
+ const identity = resolve();
208
+ expect(identity.project).toBeUndefined();
209
+ expect(identity.appName).toBeUndefined();
210
+ expect(identity.app).toEqual({});
211
+ });
212
+ });
@@ -34,7 +34,7 @@ describe('SDK Token Auth', () => {
34
34
  const bundle = {
35
35
  metadata: {
36
36
  timestamp: '2026-03-25T00:00:00Z',
37
- device: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
37
+ deviceInfo: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
38
38
  app: {},
39
39
  },
40
40
  screenshots: [],
@@ -0,0 +1,153 @@
1
+ // Runtime shake toggling + init() idempotency.
2
+ //
3
+ // Host apps put the feedback SDK behind a settings switch, which means init /
4
+ // enable / disable run repeatedly in one process. Two things used to break
5
+ // there:
6
+ //
7
+ // 1. There was no way to turn the shake catcher off short of
8
+ // setEnabled(false) (which also stops the flight recorder and the agent
9
+ // command channel) or a full re-init. `trigger` is only read inside
10
+ // init(). setShakeEnabled() fills that gap.
11
+ //
12
+ // 2. init() registered a BlackBox command handler and threw away the
13
+ // unsubscribe, so each re-init stacked another. Two inits meant two
14
+ // reloads per agent command; destroy() never cleared them either.
15
+
16
+ import { YaverFeedback } from '../YaverFeedback';
17
+ import { BlackBox } from '../BlackBox';
18
+
19
+ const mockShakeStart = jest.fn();
20
+ const mockShakeStop = jest.fn();
21
+
22
+ jest.mock('../ShakeDetector', () => ({
23
+ ShakeDetector: jest.fn().mockImplementation(() => ({
24
+ start: mockShakeStart,
25
+ stop: mockShakeStop,
26
+ })),
27
+ }));
28
+
29
+ jest.mock('react-native', () => ({
30
+ Platform: { OS: 'ios' },
31
+ DeviceEventEmitter: { emit: jest.fn(), addListener: jest.fn() },
32
+ NativeModules: {},
33
+ }));
34
+
35
+ const mockFetch = jest.fn(() =>
36
+ Promise.resolve({ ok: true, json: () => Promise.resolve({}) }),
37
+ );
38
+ global.fetch = mockFetch as any;
39
+
40
+ beforeEach(() => {
41
+ jest.clearAllMocks();
42
+ YaverFeedback.destroy();
43
+ });
44
+
45
+ afterEach(() => {
46
+ YaverFeedback.destroy();
47
+ });
48
+
49
+ describe('setShakeEnabled', () => {
50
+ it('arms the shake listener on init when trigger is shake', () => {
51
+ YaverFeedback.init({ trigger: 'shake', agentUrl: 'http://x:18080', authToken: 't' });
52
+ expect(YaverFeedback.isShakeEnabled()).toBe(true);
53
+ });
54
+
55
+ it('disarms the listener without disabling the SDK', () => {
56
+ YaverFeedback.init({ trigger: 'shake', agentUrl: 'http://x:18080', authToken: 't' });
57
+
58
+ YaverFeedback.setShakeEnabled(false);
59
+
60
+ expect(YaverFeedback.isShakeEnabled()).toBe(false);
61
+ expect(mockShakeStop).toHaveBeenCalled();
62
+ // The point of a separate toggle: the rest of the SDK stays up.
63
+ expect(YaverFeedback.isEnabled()).toBe(true);
64
+ });
65
+
66
+ it('re-arms the listener', () => {
67
+ YaverFeedback.init({ trigger: 'shake', agentUrl: 'http://x:18080', authToken: 't' });
68
+ YaverFeedback.setShakeEnabled(false);
69
+ YaverFeedback.setShakeEnabled(true);
70
+ expect(YaverFeedback.isShakeEnabled()).toBe(true);
71
+ });
72
+
73
+ it('is idempotent — repeated calls do not stack detectors', () => {
74
+ YaverFeedback.init({ trigger: 'shake', agentUrl: 'http://x:18080', authToken: 't' });
75
+ mockShakeStart.mockClear();
76
+
77
+ YaverFeedback.setShakeEnabled(true);
78
+ YaverFeedback.setShakeEnabled(true);
79
+
80
+ expect(mockShakeStart).not.toHaveBeenCalled();
81
+ expect(YaverFeedback.isShakeEnabled()).toBe(true);
82
+ });
83
+
84
+ it('persists onto config so a later enable does not resurrect the listener', () => {
85
+ YaverFeedback.init({ trigger: 'shake', agentUrl: 'http://x:18080', authToken: 't' });
86
+ YaverFeedback.setShakeEnabled(false);
87
+
88
+ YaverFeedback.setEnabled(false);
89
+ YaverFeedback.setEnabled(true);
90
+
91
+ // The user said no shake. Cycling the master switch must not undo that.
92
+ expect(YaverFeedback.isShakeEnabled()).toBe(false);
93
+ expect(YaverFeedback.getConfig()?.disableShakeGesture).toBe(true);
94
+ });
95
+
96
+ it('does not arm the listener for a non-shake trigger', () => {
97
+ YaverFeedback.init({ trigger: 'manual', agentUrl: 'http://x:18080', authToken: 't' });
98
+ expect(YaverFeedback.isShakeEnabled()).toBe(false);
99
+
100
+ YaverFeedback.setShakeEnabled(true);
101
+ expect(YaverFeedback.isShakeEnabled()).toBe(false);
102
+ });
103
+
104
+ it('no-ops before init rather than throwing', () => {
105
+ expect(() => YaverFeedback.setShakeEnabled(true)).not.toThrow();
106
+ expect(YaverFeedback.isShakeEnabled()).toBe(false);
107
+ });
108
+ });
109
+
110
+ describe('init() command-handler registration', () => {
111
+ it('does not stack command handlers across re-inits', () => {
112
+ const cfg = { trigger: 'shake' as const, agentUrl: 'http://x:18080', authToken: 't' };
113
+
114
+ YaverFeedback.init(cfg);
115
+ YaverFeedback.init(cfg);
116
+ YaverFeedback.init(cfg);
117
+
118
+ // Three inits, one live handler. Before the fix this array grew by one
119
+ // each time, so a single agent 'reload' fired three reloads.
120
+ expect((BlackBox as any).commandHandlers.length).toBe(1);
121
+ });
122
+
123
+ it('fires a reload exactly once per command after repeated inits', () => {
124
+ // The user-visible symptom the count above stands in for.
125
+ const onReload = jest.fn();
126
+ const cfg = { trigger: 'shake' as const, agentUrl: 'http://x:18080', authToken: 't', onReload };
127
+
128
+ YaverFeedback.init(cfg);
129
+ YaverFeedback.init(cfg);
130
+
131
+ for (const h of (BlackBox as any).commandHandlers) h({ command: 'reload' });
132
+
133
+ expect(onReload).toHaveBeenCalledTimes(1);
134
+ });
135
+
136
+ it('destroy() unregisters the command handler', () => {
137
+ YaverFeedback.init({ trigger: 'shake', agentUrl: 'http://x:18080', authToken: 't' });
138
+ const before = (BlackBox as any).commandHandlers.length;
139
+ expect(before).toBeGreaterThan(0);
140
+
141
+ YaverFeedback.destroy();
142
+
143
+ expect((BlackBox as any).commandHandlers.length).toBe(before - 1);
144
+ });
145
+
146
+ it('destroy() then init() leaves exactly one handler', () => {
147
+ YaverFeedback.init({ trigger: 'shake', agentUrl: 'http://x:18080', authToken: 't' });
148
+ YaverFeedback.destroy();
149
+ YaverFeedback.init({ trigger: 'shake', agentUrl: 'http://x:18080', authToken: 't' });
150
+
151
+ expect((BlackBox as any).commandHandlers.length).toBe(1);
152
+ });
153
+ });
@@ -1,3 +1,4 @@
1
+ import { DeviceEventEmitter } from 'react-native';
1
2
  import { YaverFeedback } from '../YaverFeedback';
2
3
 
3
4
  // Mock react-native: DeviceEventEmitter for event dispatch + Platform so
@@ -34,13 +35,10 @@ jest.mock('../auth', () => ({
34
35
  needsAuth: false,
35
36
  runnerDown: false,
36
37
  lastHeartbeat: Date.now(),
37
- isGuest: false,
38
- accessScope: 'owner',
39
38
  quicHost: '127.0.0.1',
40
39
  quicPort: 18080,
41
40
  },
42
41
  ],
43
- shared: [],
44
42
  })),
45
43
  DEFAULT_CONVEX_SITE_URL: 'https://example.convex.site',
46
44
  }));
@@ -54,6 +52,20 @@ beforeEach(() => {
54
52
  });
55
53
 
56
54
  describe('YaverFeedback', () => {
55
+ describe('Dogfood onboarding', () => {
56
+ it('starts with Yaver OAuth when the host has no session', () => {
57
+ YaverFeedback.init({ enabled: true });
58
+ YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app', label: 'Example' });
59
+ expect(DeviceEventEmitter.emit).toHaveBeenCalledWith('yaverFeedback:startLogin');
60
+ });
61
+
62
+ it('asks for a machine after an existing OAuth session', () => {
63
+ YaverFeedback.init({ enabled: true, authToken: 'owner-token' });
64
+ YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app', label: 'Example' });
65
+ expect(DeviceEventEmitter.emit).toHaveBeenCalledWith('yaverFeedback:startMachinePicker');
66
+ });
67
+ });
68
+
57
69
  describe('init()', () => {
58
70
  it('sets config correctly with defaults', () => {
59
71
  YaverFeedback.init({
@@ -0,0 +1,77 @@
1
+ import nacl from 'tweetnacl';
2
+ import { Buffer } from 'buffer';
3
+ import { webcrypto } from 'crypto';
4
+ import { YaverDeviceDogfood } from '../deviceDogfood';
5
+
6
+ class MemorySecureStore {
7
+ values = new Map<string, string>();
8
+ async getItemAsync(key: string) { return this.values.get(key) ?? null; }
9
+ async setItemAsync(key: string, value: string) { this.values.set(key, value); }
10
+ async deleteItemAsync(key: string) { this.values.delete(key); }
11
+ }
12
+
13
+ function response(body: unknown, status = 200): Response {
14
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
15
+ }
16
+
17
+ describe('YaverDeviceDogfood', () => {
18
+ beforeAll(() => {
19
+ if (!(globalThis as any).crypto) (globalThis as any).crypto = webcrypto;
20
+ });
21
+
22
+ afterEach(() => { jest.restoreAllMocks(); });
23
+
24
+ test('keeps identity stable and proves possession rather than trusting the UUID', async () => {
25
+ const store = new MemorySecureStore();
26
+ const client = new YaverDeviceDogfood({ appId: 'io.example.test', secureStore: store, backendUrl: 'https://dogfood.test' });
27
+ const first = await client.enrollmentInfo();
28
+ const second = await client.enrollmentInfo();
29
+ expect(second).toEqual(first);
30
+
31
+ let proof: any;
32
+ jest.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
33
+ const url = String(input);
34
+ if (url.endsWith('/dogfood/enroll/start')) return response({ status: 'pending', challenge: 'server-nonce' });
35
+ if (url.endsWith('/dogfood/enroll/prove')) { proof = JSON.parse(String(init?.body)); return response({ status: 'pending', proofVerified: true }); }
36
+ throw new Error(`unexpected URL ${url}`);
37
+ });
38
+ await client.enroll('ios');
39
+ const message = new TextEncoder().encode(`yaver-dogfood-enroll-v1\nio.example.test\n${first.installationId}\nserver-nonce`);
40
+ expect(nacl.sign.detached.verify(message, new Uint8Array(Buffer.from(proof.signature, 'base64')), new Uint8Array(Buffer.from(first.publicKey, 'base64')))).toBe(true);
41
+
42
+ const attacker = nacl.sign.keyPair();
43
+ expect(nacl.sign.detached.verify(message, new Uint8Array(Buffer.from(proof.signature, 'base64')), attacker.publicKey)).toBe(false);
44
+ });
45
+
46
+ test('re-register rotates key and installation while preserving only the logical slot', async () => {
47
+ const store = new MemorySecureStore();
48
+ const client = new YaverDeviceDogfood({ appId: 'io.example.test', secureStore: store, backendUrl: 'https://dogfood.test' });
49
+ const before = await client.enrollmentInfo();
50
+ jest.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
51
+ if (String(input).endsWith('/dogfood/enroll/start')) return response({ status: 'pending', challenge: 'rotate-nonce' });
52
+ return response({ status: 'pending', proofVerified: true });
53
+ });
54
+ await client.reRegister('ios');
55
+ const after = await client.enrollmentInfo();
56
+ expect(after.registrationSlot).toBe(before.registrationSlot);
57
+ expect(after.installationId).not.toBe(before.installationId);
58
+ expect(after.publicKey).not.toBe(before.publicKey);
59
+ });
60
+
61
+ test('exchanges an approved key proof for a short-lived scoped session', async () => {
62
+ const client = new YaverDeviceDogfood({ appId: 'io.example.test', secureStore: new MemorySecureStore(), backendUrl: 'https://dogfood.test' });
63
+ const identity = await client.enrollmentInfo();
64
+ let sessionProof: any;
65
+ jest.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
66
+ const url = String(input);
67
+ if (url.includes('/dogfood/enroll/status')) return response({ status: 'active' });
68
+ if (url.endsWith('/dogfood/session/challenge')) return response({ challenge: 'session-nonce' });
69
+ if (url.endsWith('/dogfood/session')) { sessionProof = JSON.parse(String(init?.body)); return response({ token: 'short-token', expiresAt: 123, scopes: ['feedback', 'blackbox'], projectSlug: 'example' }); }
70
+ throw new Error(`unexpected URL ${url}`);
71
+ });
72
+ const session = await client.session();
73
+ expect(session).toMatchObject({ active: true, token: 'short-token', scopes: ['feedback', 'blackbox'] });
74
+ const message = new TextEncoder().encode(`yaver-dogfood-session-v1\nio.example.test\n${identity.installationId}\nsession-nonce`);
75
+ expect(nacl.sign.detached.verify(message, new Uint8Array(Buffer.from(sessionProof.signature, 'base64')), new Uint8Array(Buffer.from(identity.publicKey, 'base64')))).toBe(true);
76
+ });
77
+ });
@@ -0,0 +1,34 @@
1
+ import { resolveSDKDogfood } from '../dogfoodPolicy';
2
+
3
+ describe('resolveSDKDogfood', () => {
4
+ it('fails closed unless Dogfood is explicitly enabled', () => {
5
+ expect(resolveSDKDogfood({ accountIds: ['acct-1'], currentAccountId: 'acct-1' })).toMatchObject({
6
+ active: false,
7
+ code: 'SDK_DOGFOOD_DISABLED',
8
+ });
9
+ });
10
+
11
+ it('requires an exact approved app-account ID', () => {
12
+ expect(resolveSDKDogfood({ enabled: true, accountIds: ['acct-1'], currentAccountId: '' }).code)
13
+ .toBe('SDK_DOGFOOD_ACCOUNT_REQUIRED');
14
+ expect(resolveSDKDogfood({ enabled: true, accountIds: ['acct-1'], currentAccountId: 'acct-10' }).code)
15
+ .toBe('SDK_DOGFOOD_ACCOUNT_NOT_ALLOWED');
16
+ expect(resolveSDKDogfood({ enabled: true, accountIds: ['acct-1'], currentAccountId: 'acct-1' })).toMatchObject({
17
+ active: true,
18
+ code: 'SDK_DOGFOOD_ACTIVE',
19
+ accountId: 'acct-1',
20
+ });
21
+ });
22
+
23
+ it('supports a key-enrolled installation without an app account', () => {
24
+ expect(resolveSDKDogfood({ enabled: true, appId: 'io.example', installationStatus: 'active' }).code)
25
+ .toBe('SDK_DOGFOOD_INSTALLATION_REQUIRED');
26
+ expect(resolveSDKDogfood({ enabled: true, appId: 'io.example', installationId: 'install-1', installationStatus: 'pending' }).code)
27
+ .toBe('SDK_DOGFOOD_INSTALLATION_NOT_ACTIVE');
28
+ expect(resolveSDKDogfood({ enabled: true, appId: 'io.example', installationId: 'install-1', installationStatus: 'active' })).toMatchObject({
29
+ active: true,
30
+ code: 'SDK_DOGFOOD_ACTIVE',
31
+ accountId: 'install-1',
32
+ });
33
+ });
34
+ });
@@ -0,0 +1,101 @@
1
+ // Target-device selection contract.
2
+ //
3
+ // This exists because pickTargetDevice used to silently reroute: when the
4
+ // user's `preferredDeviceId` was absent from the list — or present but
5
+ // carrying an empty `quicHost` — the preference block fell through to the
6
+ // generic "first fresh machine" search. The user picked their Mac mini and
7
+ // the fix landed on their laptop, with no error on either end.
8
+ //
9
+ // The contract now: an explicit preference is honoured by id alone, or the
10
+ // pick fails. Not connecting is a better failure than connecting to the
11
+ // wrong host.
12
+
13
+ import { pickTargetDevice, isDeviceFresh } from '../_core/device';
14
+ import { HEARTBEAT_STALE_MS } from '../_core/constants';
15
+ import type { CoreDevice } from '../_core/device';
16
+
17
+ const NOW = 1_700_000_000_000;
18
+
19
+ function device(over: Partial<CoreDevice> & { deviceId: string }): CoreDevice {
20
+ return {
21
+ name: over.deviceId,
22
+ platform: 'darwin',
23
+ isOnline: true,
24
+ lastHeartbeat: NOW,
25
+ quicHost: '100.64.0.1',
26
+ quicPort: 18080,
27
+ ...over,
28
+ } as CoreDevice;
29
+ }
30
+
31
+ beforeEach(() => {
32
+ jest.spyOn(Date, 'now').mockReturnValue(NOW);
33
+ });
34
+
35
+ afterEach(() => {
36
+ jest.restoreAllMocks();
37
+ });
38
+
39
+ describe('pickTargetDevice — explicit preference', () => {
40
+ it('returns the preferred device when it is fresh', () => {
41
+ const macmini = device({ deviceId: 'macmini' });
42
+ const laptop = device({ deviceId: 'laptop' });
43
+ expect(pickTargetDevice([laptop, macmini], 'macmini')).toBe(macmini);
44
+ });
45
+
46
+ it('returns the preferred device even when it is STALE, rather than rerouting', () => {
47
+ const macmini = device({
48
+ deviceId: 'macmini',
49
+ lastHeartbeat: NOW - (HEARTBEAT_STALE_MS + 1),
50
+ });
51
+ const laptop = device({ deviceId: 'laptop' });
52
+ expect(isDeviceFresh(macmini)).toBe(false);
53
+ expect(pickTargetDevice([laptop, macmini], 'macmini')).toBe(macmini);
54
+ });
55
+
56
+ it('returns the preferred device even with NO quicHost — relay addresses it by id', () => {
57
+ // This is the off-LAN shape: no LAN address is published, and the relay
58
+ // route is `<relay>/d/<deviceId>`. An empty quicHost is not a reason to
59
+ // reroute; it is the normal remote case.
60
+ const macmini = device({ deviceId: 'macmini', quicHost: '' });
61
+ const laptop = device({ deviceId: 'laptop' });
62
+ expect(pickTargetDevice([laptop, macmini], 'macmini')).toBe(macmini);
63
+ });
64
+
65
+ it('returns null when the preferred id is absent — never falls through to another machine', () => {
66
+ const laptop = device({ deviceId: 'laptop' });
67
+ const desktop = device({ deviceId: 'desktop' });
68
+ expect(pickTargetDevice([laptop, desktop], 'macmini')).toBeNull();
69
+ });
70
+
71
+ it('returns null for an unknown preference even when exactly one healthy machine exists', () => {
72
+ // The tempting "well, there's only one, just use it" case. Still wrong:
73
+ // the user asked for a specific host.
74
+ expect(pickTargetDevice([device({ deviceId: 'laptop' })], 'macmini')).toBeNull();
75
+ });
76
+ });
77
+
78
+ describe('pickTargetDevice — no preference', () => {
79
+ it('prefers a fresh device with a quicHost over a stale one', () => {
80
+ const stale = device({
81
+ deviceId: 'stale',
82
+ lastHeartbeat: NOW - (HEARTBEAT_STALE_MS + 1),
83
+ });
84
+ const fresh = device({ deviceId: 'fresh' });
85
+ expect(pickTargetDevice([stale, fresh])).toBe(fresh);
86
+ });
87
+
88
+ it('falls back to an online device with a quicHost when none are fresh', () => {
89
+ const offline = device({ deviceId: 'offline', isOnline: false });
90
+ const online = device({
91
+ deviceId: 'online',
92
+ lastHeartbeat: NOW - (HEARTBEAT_STALE_MS + 1),
93
+ });
94
+ expect(pickTargetDevice([offline, online])).toBe(online);
95
+ });
96
+
97
+ it('returns null for an empty list', () => {
98
+ expect(pickTargetDevice([], 'macmini')).toBeNull();
99
+ expect(pickTargetDevice([])).toBeNull();
100
+ });
101
+ });