yaver-feedback-react-native 0.8.13 → 0.9.2

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 (76) hide show
  1. package/app.plugin.js +126 -9
  2. package/dist/BlackBox.js +9 -1
  3. package/dist/DeployPanel.js +65 -0
  4. package/dist/Discovery.js +13 -2
  5. package/dist/FeedbackModal.js +477 -66
  6. package/dist/MachinePickerScreen.js +14 -5
  7. package/dist/P2PClient.d.ts +94 -1
  8. package/dist/P2PClient.js +281 -2
  9. package/dist/ShakeDetector.js +2 -0
  10. package/dist/VibeChatScreen.d.ts +6 -1
  11. package/dist/VibeChatScreen.js +204 -1
  12. package/dist/YaverFeedback.d.ts +98 -0
  13. package/dist/YaverFeedback.js +509 -46
  14. package/dist/__tests__/BlackBox.relayPassword.test.d.ts +1 -0
  15. package/dist/__tests__/BlackBox.relayPassword.test.js +105 -0
  16. package/dist/__tests__/BlackBoxAutoStart.test.d.ts +1 -0
  17. package/dist/__tests__/BlackBoxAutoStart.test.js +91 -0
  18. package/dist/__tests__/BlackBoxAutoStartColdStart.test.d.ts +1 -0
  19. package/dist/__tests__/BlackBoxAutoStartColdStart.test.js +156 -0
  20. package/dist/__tests__/BrowserLaneIcon.test.d.ts +3 -0
  21. package/dist/__tests__/BrowserLaneIcon.test.js +80 -0
  22. package/dist/__tests__/P2PClient.test.js +2 -2
  23. package/dist/__tests__/ReportIdentity.test.d.ts +1 -0
  24. package/dist/__tests__/ReportIdentity.test.js +168 -0
  25. package/dist/__tests__/SDKToken.test.js +1 -1
  26. package/dist/__tests__/ShakeToggle.test.d.ts +1 -0
  27. package/dist/__tests__/ShakeToggle.test.js +121 -0
  28. package/dist/__tests__/pickTargetDevice.test.d.ts +1 -0
  29. package/dist/__tests__/pickTargetDevice.test.js +89 -0
  30. package/dist/__tests__/reloadActions.test.d.ts +1 -0
  31. package/dist/__tests__/reloadActions.test.js +129 -0
  32. package/dist/__tests__/reloadActionsParity.test.d.ts +1 -0
  33. package/dist/__tests__/reloadActionsParity.test.js +42 -0
  34. package/dist/__tests__/types.test.js +5 -5
  35. package/dist/_core/device.d.ts +19 -9
  36. package/dist/_core/device.js +20 -14
  37. package/dist/capture.d.ts +6 -0
  38. package/dist/capture.js +53 -0
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.js +11 -1
  41. package/dist/reloadActions.d.ts +88 -0
  42. package/dist/reloadActions.js +200 -0
  43. package/dist/storeShots.d.ts +67 -0
  44. package/dist/storeShots.js +137 -0
  45. package/dist/types.d.ts +215 -3
  46. package/dist/voice.d.ts +61 -0
  47. package/dist/voice.js +246 -0
  48. package/package.json +14 -3
  49. package/src/BlackBox.ts +10 -1
  50. package/src/DeployPanel.tsx +74 -0
  51. package/src/Discovery.ts +13 -2
  52. package/src/FeedbackModal.tsx +563 -87
  53. package/src/MachinePickerScreen.tsx +12 -3
  54. package/src/P2PClient.ts +314 -2
  55. package/src/ShakeDetector.ts +1 -0
  56. package/src/VibeChatScreen.tsx +219 -0
  57. package/src/YaverFeedback.ts +498 -46
  58. package/src/__tests__/BlackBox.relayPassword.test.ts +129 -0
  59. package/src/__tests__/BlackBoxAutoStart.test.ts +111 -0
  60. package/src/__tests__/BlackBoxAutoStartColdStart.test.ts +191 -0
  61. package/src/__tests__/BrowserLaneIcon.test.ts +85 -0
  62. package/src/__tests__/P2PClient.test.ts +2 -2
  63. package/src/__tests__/ReportIdentity.test.ts +203 -0
  64. package/src/__tests__/SDKToken.test.ts +1 -1
  65. package/src/__tests__/ShakeToggle.test.ts +153 -0
  66. package/src/__tests__/pickTargetDevice.test.ts +101 -0
  67. package/src/__tests__/reloadActions.test.ts +171 -0
  68. package/src/__tests__/reloadActionsParity.test.ts +49 -0
  69. package/src/__tests__/types.test.ts +5 -5
  70. package/src/_core/device.ts +20 -14
  71. package/src/capture.ts +51 -0
  72. package/src/index.ts +21 -0
  73. package/src/reloadActions.ts +273 -0
  74. package/src/storeShots.ts +189 -0
  75. package/src/types.ts +217 -3
  76. package/src/voice.ts +270 -0
@@ -0,0 +1,203 @@
1
+ import { resolveReportIdentity } from '../P2PClient';
2
+
3
+ // resolveAppIdentity() has always fed /vibing/execute and /dev/reload-app so
4
+ // the agent could route "vibe on THIS app" to the right repo, but nothing fed
5
+ // /feedback — reports carried no identity at all, so the agent's fix router
6
+ // had nothing to resolve and fell back to whatever directory it was sitting
7
+ // in. resolveReportIdentity() closes that gap for the feedback path.
8
+
9
+ jest.mock('react-native', () => ({
10
+ Platform: { OS: 'ios' },
11
+ NativeModules: {},
12
+ }));
13
+
14
+ /** Mirrors mobile/app.json in the Talos repo — the SDK's first external consumer. */
15
+ const EXPO_CONFIG = {
16
+ name: 'Talos',
17
+ slug: 'talos-mobile',
18
+ version: '1.9.157',
19
+ ios: { bundleIdentifier: 'works.talos.mobile', buildNumber: '427' },
20
+ android: { package: 'works.talos.mobile', versionCode: 423 },
21
+ };
22
+
23
+ function mockExpoConstants(expoConfig: unknown, extra: Record<string, unknown> = {}) {
24
+ jest.doMock('expo-constants', () => ({ default: { expoConfig, ...extra } }), { virtual: true });
25
+ }
26
+
27
+ beforeEach(() => {
28
+ jest.resetModules();
29
+ });
30
+
31
+ describe('resolveReportIdentity', () => {
32
+ it('resolves app name and bundle id from the Expo config', () => {
33
+ mockExpoConstants(EXPO_CONFIG);
34
+ const { resolveReportIdentity: resolve } = require('../P2PClient');
35
+
36
+ const identity = resolve();
37
+
38
+ // The agent's fix router reads DeviceInfo.AppName first.
39
+ expect(identity.appName).toBe('Talos');
40
+ // ...and resolves the project by bundle id, the only unambiguous key.
41
+ expect(identity.project?.bundleId).toBe('works.talos.mobile');
42
+ expect(identity.project?.projectName).toBe('Talos');
43
+ expect(identity.project?.appName).toBe('Talos');
44
+ expect(identity.project?.surface).toBe('mobile');
45
+ expect(identity.app.bundleId).toBe('works.talos.mobile');
46
+ });
47
+
48
+ it('never reports a project path', () => {
49
+ mockExpoConstants(EXPO_CONFIG);
50
+ const { resolveReportIdentity: resolve } = require('../P2PClient');
51
+
52
+ // The agent ignores client-supplied paths on feedback reports (an
53
+ // untrusted guest could otherwise aim the fix task's CWD at ~/.ssh).
54
+ // Sending one would be misleading at best.
55
+ expect(resolve().project?.projectPath).toBeUndefined();
56
+ });
57
+
58
+ it('prefers the native runtime version over the manifest version', () => {
59
+ mockExpoConstants(EXPO_CONFIG, { nativeAppVersion: '1.9.158', nativeBuildVersion: '428' });
60
+ const { resolveReportIdentity: resolve } = require('../P2PClient');
61
+
62
+ const identity = resolve();
63
+ // A stale manifest shouldn't misreport which build is actually running.
64
+ expect(identity.app.version).toBe('1.9.158');
65
+ expect(identity.app.buildNumber).toBe('428');
66
+ });
67
+
68
+ it('falls back to the manifest version when no native version is exposed', () => {
69
+ mockExpoConstants(EXPO_CONFIG);
70
+ const { resolveReportIdentity: resolve } = require('../P2PClient');
71
+
72
+ const identity = resolve();
73
+ expect(identity.app.version).toBe('1.9.157');
74
+ expect(identity.app.buildNumber).toBe('427');
75
+ });
76
+
77
+ // Inside Yaver's container the ambient runtime IS Yaver. These are the
78
+ // cases where getting it wrong routes a fix task into yaver.io and edits
79
+ // the SDK instead of the app under test.
80
+ describe('inside the Yaver container (Hermes guest)', () => {
81
+ const YAVER_CONFIG = {
82
+ name: 'Yaver',
83
+ version: '1.99.306',
84
+ ios: { bundleIdentifier: 'io.yaver.mobile' },
85
+ android: { package: 'io.yaver.mobile' },
86
+ };
87
+
88
+ function mockContainer(yaverInfo: Record<string, unknown>) {
89
+ jest.doMock('react-native', () => ({
90
+ Platform: { OS: 'ios' },
91
+ NativeModules: { YaverInfo: { isYaver: true, ...yaverInfo } },
92
+ }));
93
+ // The host's manifest — what expo-constants answers for a guest bundle.
94
+ mockExpoConstants(YAVER_CONFIG);
95
+ }
96
+
97
+ it("never reports the host's bundle id as the guest's", () => {
98
+ mockContainer({ inheritedGuestProjectName: 'talos / mobile' });
99
+ const { resolveReportIdentity: resolve } = require('../P2PClient');
100
+
101
+ const identity = resolve();
102
+
103
+ // The agent routes on bundle id FIRST. Leaking io.yaver.mobile here
104
+ // would resolve to yaver.io and the project name would never be read.
105
+ expect(identity.project?.bundleId).toBeUndefined();
106
+ expect(identity.app.bundleId).toBeUndefined();
107
+ // Nor should the host's version masquerade as the guest's build.
108
+ expect(identity.app.version).toBeUndefined();
109
+ });
110
+
111
+ it('reports the guest project Yaver pinned', () => {
112
+ mockContainer({ inheritedGuestProjectName: 'talos / mobile' });
113
+ const { resolveReportIdentity: resolve } = require('../P2PClient');
114
+
115
+ const identity = resolve();
116
+ expect(identity.appName).toBe('talos / mobile');
117
+ expect(identity.project?.projectName).toBe('talos / mobile');
118
+ });
119
+
120
+ it("lets the app's own declaration win over the pinned project", () => {
121
+ mockContainer({ inheritedGuestProjectName: 'something-else' });
122
+ const { resolveReportIdentity: resolve } = require('../P2PClient');
123
+
124
+ // A bundle knows its own identity; nothing ambient should override it.
125
+ const identity = resolve({ projectName: 'Talos', bundleId: 'works.talos.mobile' });
126
+ expect(identity.appName).toBe('Talos');
127
+ expect(identity.project?.bundleId).toBe('works.talos.mobile');
128
+ });
129
+
130
+ it('reports nothing rather than something wrong when no project is pinned', () => {
131
+ mockContainer({});
132
+ const { resolveReportIdentity: resolve } = require('../P2PClient');
133
+
134
+ // Better for the agent to reject/fall back than to confidently edit
135
+ // the wrong repo.
136
+ const identity = resolve();
137
+ expect(identity.project).toBeUndefined();
138
+ expect(identity.appName).toBeUndefined();
139
+ expect(identity.app).toEqual({});
140
+ });
141
+ });
142
+
143
+ it('lets an explicit declaration override the ambient identity', () => {
144
+ mockExpoConstants(EXPO_CONFIG);
145
+ const { resolveReportIdentity: resolve } = require('../P2PClient');
146
+
147
+ const identity = resolve({ projectName: 'Override', bundleId: 'com.override.app' });
148
+ expect(identity.appName).toBe('Override');
149
+ expect(identity.project?.bundleId).toBe('com.override.app');
150
+ });
151
+
152
+ it('passes declared surfaces stacks and voice metadata through', () => {
153
+ mockExpoConstants(EXPO_CONFIG);
154
+ const { resolveReportIdentity: resolve } = require('../P2PClient');
155
+
156
+ const identity = resolve({
157
+ projectName: 'Omni',
158
+ bundleId: 'io.example.omni',
159
+ surface: 'vision',
160
+ surfaces: ['mobile', 'web', 'watch', 'tv', 'car', 'vision'],
161
+ stack: 'react-native-expo',
162
+ stacks: ['react-native-expo', 'nextjs', 'yaver-xml'],
163
+ testSurfaces: ['rn-hermes', 'browser', 'visionos-simulator'],
164
+ feedbackSdk: 'yaver-feedback-react-native',
165
+ feedbackTransport: 'device-sdk',
166
+ voiceCapabilities: ['stt', 'tts', 'device-mic'],
167
+ sttProvider: 'deepgram',
168
+ ttsProvider: 'local',
169
+ });
170
+
171
+ expect(identity.project).toMatchObject({
172
+ surface: 'vision',
173
+ surfaces: ['mobile', 'web', 'watch', 'tv', 'car', 'vision'],
174
+ stack: 'react-native-expo',
175
+ stacks: ['react-native-expo', 'nextjs', 'yaver-xml'],
176
+ testSurfaces: ['rn-hermes', 'browser', 'visionos-simulator'],
177
+ feedbackSdk: 'yaver-feedback-react-native',
178
+ feedbackTransport: 'device-sdk',
179
+ voiceCapabilities: ['stt', 'tts', 'device-mic'],
180
+ sttProvider: 'deepgram',
181
+ ttsProvider: 'local',
182
+ });
183
+ expect(identity.project?.projectPath).toBeUndefined();
184
+ });
185
+
186
+ it('omits the project block when nothing identifies the app', () => {
187
+ // Bare RN with no expo-constants and no native modules. The report must
188
+ // still upload — the agent just resolves it by its own means.
189
+ jest.doMock(
190
+ 'expo-constants',
191
+ () => {
192
+ throw new Error('not installed');
193
+ },
194
+ { virtual: true },
195
+ );
196
+ const { resolveReportIdentity: resolve } = require('../P2PClient');
197
+
198
+ const identity = resolve();
199
+ expect(identity.project).toBeUndefined();
200
+ expect(identity.appName).toBeUndefined();
201
+ expect(identity.app).toEqual({});
202
+ });
203
+ });
@@ -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
+ });
@@ -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
+ });
@@ -0,0 +1,171 @@
1
+ import {
2
+ describeReloadFailure,
3
+ reloadActions,
4
+ reloadFrameworkFamily,
5
+ reloadRequest,
6
+ RELOAD_APP_PATH,
7
+ RELOAD_PATH,
8
+ } from '../reloadActions';
9
+
10
+ const DEV = { isDevBuild: true, connected: true };
11
+
12
+ describe('reloadActions — the production guard', () => {
13
+ // THIS IS THE GUARD. Prove it by breaking it: flip `if (!opts.isDevBuild)`
14
+ // in reloadActions.ts to `if (opts.isDevBuild)` and this test fails while
15
+ // every other test in this file still passes.
16
+ it('returns NOTHING in a production build, even with a healthy dev server', () => {
17
+ const actions = reloadActions(
18
+ { running: true, framework: 'vite' },
19
+ { isDevBuild: false, connected: true, includeRebuild: true },
20
+ );
21
+ expect(actions).toEqual([]);
22
+ });
23
+
24
+ it('returns actions in a dev build', () => {
25
+ const actions = reloadActions({ running: true, framework: 'vite' }, DEV);
26
+ expect(actions.map((a) => a.id)).toEqual(['hot', 'full']);
27
+ expect(actions.every((a) => a.enabled)).toBe(true);
28
+ });
29
+ });
30
+
31
+ describe('reloadFrameworkFamily', () => {
32
+ it.each([
33
+ ['flutter', 'flutter'],
34
+ ['expo', 'react-native'],
35
+ ['react-native', 'react-native'],
36
+ ['vite', 'web'],
37
+ ['nextjs', 'web'],
38
+ ['', 'unknown'],
39
+ ['godot', 'unknown'],
40
+ ])('maps %s → %s', (framework, family) => {
41
+ expect(reloadFrameworkFamily(framework)).toBe(family);
42
+ });
43
+ });
44
+
45
+ describe('per-stack labels', () => {
46
+ it('calls the full action a Hot Restart on Flutter (stdin R), not a Full Reload', () => {
47
+ const actions = reloadActions({ running: true, framework: 'flutter' }, DEV);
48
+ expect(actions.find((a) => a.id === 'hot')!.label).toBe('Hot Reload');
49
+ expect(actions.find((a) => a.id === 'full')!.label).toBe('Hot Restart');
50
+ expect(actions.find((a) => a.id === 'full')!.hint).toContain('(R)');
51
+ });
52
+
53
+ it('calls it a Full Reload everywhere else', () => {
54
+ for (const framework of ['expo', 'vite', 'nextjs']) {
55
+ const actions = reloadActions({ running: true, framework }, DEV);
56
+ expect(actions.find((a) => a.id === 'full')!.label).toBe('Full Reload');
57
+ }
58
+ });
59
+ });
60
+
61
+ describe('URL / payload construction', () => {
62
+ it('sends fast for hot and full for full, both to /dev/reload', () => {
63
+ const actions = reloadActions({ running: true, framework: 'flutter' }, DEV);
64
+ expect(reloadRequest(actions[0])).toEqual({
65
+ method: 'POST',
66
+ path: RELOAD_PATH,
67
+ body: { mode: 'fast' },
68
+ });
69
+ expect(reloadRequest(actions[1])).toEqual({
70
+ method: 'POST',
71
+ path: RELOAD_PATH,
72
+ body: { mode: 'full' },
73
+ });
74
+ });
75
+
76
+ it('routes the RN bundle rebuild to /dev/reload-app', () => {
77
+ const actions = reloadActions(
78
+ { running: false },
79
+ { ...DEV, includeRebuild: true },
80
+ );
81
+ const rebuild = actions.find((a) => a.id === 'rebuild')!;
82
+ expect(reloadRequest(rebuild)).toEqual({
83
+ method: 'POST',
84
+ path: RELOAD_APP_PATH,
85
+ body: { mode: 'bundle' },
86
+ });
87
+ });
88
+ });
89
+
90
+ describe('a blocked action NAMES the blocker', () => {
91
+ it('names the missing dev server and the command that starts it', () => {
92
+ const actions = reloadActions({ running: false }, { ...DEV, machineLabel: 'primary' });
93
+ expect(actions).toHaveLength(2);
94
+ for (const action of actions) {
95
+ expect(action.enabled).toBe(false);
96
+ expect(action.disabledReason).toContain('primary');
97
+ expect(action.disabledReason).toContain('yaver dev start');
98
+ }
99
+ });
100
+
101
+ it('names "still building" rather than pretending nothing is running', () => {
102
+ const actions = reloadActions({ running: true, building: true, framework: 'expo' }, DEV);
103
+ expect(actions[0].disabledReason).toContain('still building');
104
+ });
105
+
106
+ it('names the missing machine when disconnected', () => {
107
+ const actions = reloadActions(
108
+ { running: true, framework: 'expo' },
109
+ { isDevBuild: true, connected: false },
110
+ );
111
+ expect(actions[0].disabledReason).toContain('Not connected');
112
+ });
113
+
114
+ it('keeps Rebuild Bundle ENABLED with no dev server — that is its whole point', () => {
115
+ const actions = reloadActions({ running: false }, { ...DEV, includeRebuild: true });
116
+ const rebuild = actions.find((a) => a.id === 'rebuild')!;
117
+ expect(rebuild.enabled).toBe(true);
118
+ expect(rebuild.disabledReason).toBeUndefined();
119
+ });
120
+
121
+ it('disables Rebuild Bundle when there is no machine', () => {
122
+ const actions = reloadActions(
123
+ { running: true },
124
+ { isDevBuild: true, connected: false, includeRebuild: true },
125
+ );
126
+ expect(actions.find((a) => a.id === 'rebuild')!.enabled).toBe(false);
127
+ });
128
+ });
129
+
130
+ describe('describeReloadFailure names a cause, never "failed"', () => {
131
+ it('503 → no dev server', () => {
132
+ expect(describeReloadFailure(503, 'dev server not available')).toContain(
133
+ 'No dev server is running',
134
+ );
135
+ });
136
+
137
+ it('framework cannot hot reload → says so and points at the alternative', () => {
138
+ const msg = describeReloadFailure(500, 'unity does not support hot reload', {
139
+ running: true,
140
+ framework: 'unity',
141
+ });
142
+ expect(msg).toContain('unity');
143
+ expect(msg).toContain('Rebuild Bundle');
144
+ });
145
+
146
+ it('loopback connection refused → the dev server is not listening', () => {
147
+ const msg = describeReloadFailure(
148
+ 502,
149
+ 'Get "http://127.0.0.1:8081/reload": dial tcp 127.0.0.1:8081: connect: connection refused',
150
+ );
151
+ expect(msg).toContain('not listening');
152
+ expect(msg).toContain('yaver dev start');
153
+ });
154
+
155
+ it('401/403 → session, not server', () => {
156
+ expect(describeReloadFailure(401, '')).toContain('sign in again');
157
+ expect(describeReloadFailure(403, '')).toContain('sign in again');
158
+ });
159
+
160
+ it('404 → the agent is too old, and says how to update it', () => {
161
+ expect(describeReloadFailure(404, 'not found')).toContain('yaver-cli@latest');
162
+ });
163
+
164
+ it('5xx → points at the agent log', () => {
165
+ expect(describeReloadFailure(500, 'boom')).toContain('yaver logs');
166
+ });
167
+
168
+ it('status 0 (transport never answered) → machine reachability', () => {
169
+ expect(describeReloadFailure(0, '')).toContain('yaver serve');
170
+ });
171
+ });
@@ -0,0 +1,49 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ /**
5
+ * `reloadActions.ts` is duplicated into yaver-feedback-react-native and
6
+ * yaver-feedback-web on purpose — they are two independently published npm
7
+ * packages, and neither may depend on the other.
8
+ *
9
+ * Duplication without a guard is DRIFT, and drift in this file is the worst
10
+ * kind: it is a *policy* file. If the web copy stops returning [] for a
11
+ * production build and the RN copy still does, the two SDKs disagree about
12
+ * whether a shipped app may show a reload button — and nothing in `tsc`
13
+ * notices, because they are separate compilation units.
14
+ *
15
+ * This is the same shape as beaconParity.test.ts in mobile/: read both
16
+ * sources, assert they agree. Prove it by breaking it — change one word in
17
+ * either copy and this test fails.
18
+ */
19
+ const RN_COPY = join(__dirname, '..', 'reloadActions.ts');
20
+ const WEB_COPY = join(
21
+ __dirname,
22
+ '..', '..', '..', // sdk/feedback/react-native
23
+ 'web', 'src', 'reloadActions.ts',
24
+ );
25
+
26
+ describe('reloadActions parity between the RN and web SDKs', () => {
27
+ it('both copies exist', () => {
28
+ expect(() => readFileSync(RN_COPY, 'utf8')).not.toThrow();
29
+ expect(() => readFileSync(WEB_COPY, 'utf8')).not.toThrow();
30
+ });
31
+
32
+ it('is byte-identical across the two packages', () => {
33
+ const rn = readFileSync(RN_COPY, 'utf8');
34
+ const web = readFileSync(WEB_COPY, 'utf8');
35
+ expect(rn).toEqual(web);
36
+ });
37
+
38
+ it('still carries the production guard in BOTH copies', () => {
39
+ // Named separately from the byte comparison so that if someone
40
+ // legitimately reformats both files the failure still points at the one
41
+ // line that actually matters.
42
+ for (const [name, path] of [['react-native', RN_COPY], ['web', WEB_COPY]] as const) {
43
+ const source = readFileSync(path, 'utf8');
44
+ expect(`${name}: ${source.includes('if (!opts.isDevBuild) return [];')}`).toEqual(
45
+ `${name}: true`,
46
+ );
47
+ }
48
+ });
49
+ });