yaver-feedback-react-native 0.8.6 → 0.8.8

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.
package/dist/types.d.ts CHANGED
@@ -19,6 +19,61 @@ export interface RunnerBrowserAuthSession {
19
19
  updatedAt: number;
20
20
  completedAt?: number;
21
21
  }
22
+ export interface IncidentEvent {
23
+ id: string;
24
+ timestamp: number;
25
+ severity: 'info' | 'warn' | 'error' | 'fatal';
26
+ category: string;
27
+ code: string;
28
+ source: string;
29
+ title: string;
30
+ userMessage: string;
31
+ technicalInfo?: string;
32
+ suggestedAction?: string;
33
+ operationId?: string;
34
+ deviceId?: string;
35
+ projectPath?: string;
36
+ target?: string;
37
+ logsAvailable: boolean;
38
+ logRefs?: string[];
39
+ correlationId?: string;
40
+ recoverable: boolean;
41
+ metadata?: Record<string, unknown>;
42
+ resolved?: boolean;
43
+ }
44
+ export interface OperationState {
45
+ id: string;
46
+ kind: string;
47
+ status: string;
48
+ phase?: string;
49
+ message?: string;
50
+ progress?: number;
51
+ deviceId?: string;
52
+ projectPath?: string;
53
+ startedAt: number;
54
+ updatedAt: number;
55
+ incidentIds?: string[];
56
+ metadata?: Record<string, unknown>;
57
+ }
58
+ export interface CapabilityTargetReadiness {
59
+ enabled: boolean;
60
+ reasonCode?: string;
61
+ reason?: string;
62
+ suggestedAction?: string;
63
+ notes?: string[];
64
+ }
65
+ export interface CapabilitySnapshot {
66
+ generatedAt: string;
67
+ machine?: Record<string, unknown>;
68
+ infra?: Record<string, unknown>;
69
+ connectivity?: {
70
+ directAvailable?: boolean;
71
+ relayConfigured?: boolean;
72
+ tunnelConfigured?: boolean;
73
+ tailscaleAvailable?: boolean;
74
+ };
75
+ targets: Record<string, CapabilityTargetReadiness>;
76
+ }
22
77
  export interface FeedbackConfig {
23
78
  /** URL of the Yaver agent (e.g. "http://192.168.1.10:18080"). If omitted, auto-discovery is used. */
24
79
  agentUrl?: string;
@@ -63,6 +118,35 @@ export interface FeedbackConfig {
63
118
  preferredDeviceId?: string;
64
119
  /** How feedback collection is triggered */
65
120
  trigger?: 'shake' | 'floating-button' | 'manual';
121
+ /**
122
+ * Non-default escape hatch for host apps that want the SDK without
123
+ * shake gesture handling. When enabled:
124
+ * - the SDK does not start ShakeDetector
125
+ * - if `quickIcon` was left as `'auto'`/unset, it is promoted to `'always'`
126
+ * - the app should rely on the draggable quick icon or explicit
127
+ * `YaverFeedback.startReport()` calls instead
128
+ *
129
+ * Intended for builds where another surface owns motion / haptics.
130
+ */
131
+ disableShakeGesture?: boolean;
132
+ /**
133
+ * Auto-open the BlackBox SSE command channel after init() returns.
134
+ *
135
+ * Default: `true` (0.8.8+). Listens for `reload`, `reload_bundle`,
136
+ * and `status` commands the agent broadcasts after a vibe-coding
137
+ * task commits a fix — drives the auto-reload loop without the host
138
+ * app needing to call `BlackBox.start()` manually.
139
+ *
140
+ * The auto-start is deferred 500ms after init() and gated on having
141
+ * BOTH `agentUrl` and `authToken` resolved, to avoid the iOS 18.3.1
142
+ * rope-string SIGSEGV that the early auto-start in 0.7.6 hit when
143
+ * the agent was in needs-auth mode (tight 401-retry loop in SSE).
144
+ *
145
+ * Set `false` if your host app wants to gate BlackBox start on its
146
+ * own state machine (e.g. only after the user opts into telemetry).
147
+ * Calling `BlackBox.start()` manually is still safe — it's idempotent.
148
+ */
149
+ autoStartBlackBox?: boolean;
66
150
  /**
67
151
  * Small tap-to-open icon that floats above the app so the user
68
152
  * doesn't have to shake every time they want to open feedback.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yaver-feedback-react-native",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "Visual feedback SDK for Yaver \u2014 bug reports, screen recording, voice annotations, and local-first developer workflows",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -120,8 +120,8 @@ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
120
120
  // yellows from phone↔backend clock skew around the 89-90 s mark.
121
121
  //
122
122
  // `runnerDown` intentionally does NOT flip the dot. That flag
123
- // tracks whether the AI runner (claude-code, aider, ...) is
124
- // healthy — a separate concern from "can I reach this machine?"
123
+ // tracks whether the AI runner (claude-code / codex / opencode)
124
+ // is healthy — a separate concern from "can I reach this machine?"
125
125
  // Mobile app surfaces runner issues via a separate badge, not
126
126
  // this dot. Picker's job is reachability, nothing more.
127
127
  const effectivelyReachable = probe?.reachable === true;
package/src/P2PClient.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import { Platform } from 'react-native';
2
- import { FeedbackBundle, RunnerBrowserAuthSession, TestSession, VoiceCapability } from './types';
2
+ import {
3
+ CapabilitySnapshot,
4
+ FeedbackBundle,
5
+ IncidentEvent,
6
+ OperationState,
7
+ RunnerBrowserAuthSession,
8
+ TestSession,
9
+ VoiceCapability,
10
+ } from './types';
3
11
 
4
12
  export interface FeedbackEvent {
5
13
  type: string;
@@ -191,6 +199,67 @@ export class P2PClient {
191
199
  try { await fetch(url, { method: 'POST', headers: this.authHeaders() }); } catch { /* best-effort */ }
192
200
  }
193
201
 
202
+ async capabilitySnapshot(): Promise<CapabilitySnapshot | null> {
203
+ try {
204
+ const resp = await fetch(`${this.baseUrl}/capabilities/snapshot`, { headers: this.authHeaders() });
205
+ if (!resp.ok) return null;
206
+ const data = await resp.json().catch(() => ({} as Record<string, unknown>));
207
+ return (data.snapshot ?? null) as CapabilitySnapshot | null;
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
212
+
213
+ async incidents(opts: {
214
+ category?: string;
215
+ severity?: string;
216
+ code?: string;
217
+ deviceId?: string;
218
+ projectPath?: string;
219
+ includeResolved?: boolean;
220
+ limit?: number;
221
+ } = {}): Promise<IncidentEvent[]> {
222
+ try {
223
+ const url = new URL(`${this.baseUrl}/incidents`);
224
+ if (opts.category) url.searchParams.set('category', opts.category);
225
+ if (opts.severity) url.searchParams.set('severity', opts.severity);
226
+ if (opts.code) url.searchParams.set('code', opts.code);
227
+ if (opts.deviceId) url.searchParams.set('device', opts.deviceId);
228
+ if (opts.projectPath) url.searchParams.set('projectPath', opts.projectPath);
229
+ if (opts.includeResolved) url.searchParams.set('includeResolved', '1');
230
+ if (typeof opts.limit === 'number') url.searchParams.set('limit', String(opts.limit));
231
+ const resp = await fetch(url.toString(), { headers: this.authHeaders() });
232
+ if (!resp.ok) return [];
233
+ const data = await resp.json().catch(() => ({} as Record<string, unknown>));
234
+ return Array.isArray(data.incidents) ? (data.incidents as IncidentEvent[]) : [];
235
+ } catch {
236
+ return [];
237
+ }
238
+ }
239
+
240
+ async operations(opts: {
241
+ kind?: string;
242
+ status?: string;
243
+ deviceId?: string;
244
+ projectPath?: string;
245
+ limit?: number;
246
+ } = {}): Promise<OperationState[]> {
247
+ try {
248
+ const url = new URL(`${this.baseUrl}/operations`);
249
+ if (opts.kind) url.searchParams.set('kind', opts.kind);
250
+ if (opts.status) url.searchParams.set('status', opts.status);
251
+ if (opts.deviceId) url.searchParams.set('device', opts.deviceId);
252
+ if (opts.projectPath) url.searchParams.set('projectPath', opts.projectPath);
253
+ if (typeof opts.limit === 'number') url.searchParams.set('limit', String(opts.limit));
254
+ const resp = await fetch(url.toString(), { headers: this.authHeaders() });
255
+ if (!resp.ok) return [];
256
+ const data = await resp.json().catch(() => ({} as Record<string, unknown>));
257
+ return Array.isArray(data.operations) ? (data.operations as OperationState[]) : [];
258
+ } catch {
259
+ return [];
260
+ }
261
+ }
262
+
194
263
  /** Health check — returns true if the agent is reachable. */
195
264
  async health(): Promise<boolean> {
196
265
  try {
@@ -137,6 +137,9 @@ export class YaverFeedback {
137
137
  autoLogin: true,
138
138
  ...cfg,
139
139
  };
140
+ if (config.disableShakeGesture && (!config.quickIcon || config.quickIcon === 'auto')) {
141
+ config.quickIcon = 'always';
142
+ }
140
143
  firstShakeFired = false;
141
144
 
142
145
  // Route the in-SDK login screen to prod yaver.io by default; callers may
@@ -155,11 +158,17 @@ export class YaverFeedback {
155
158
  config.convexUrl = cfg.authConvexSiteUrl ?? DEFAULT_CONVEX_SITE_URL;
156
159
  }
157
160
 
158
- // Default: enabled only in dev mode
161
+ // Default: enabled. Pre-0.8.8 the SDK only enabled shake in dev
162
+ // builds (`__DEV__`), but apps that bundle the SDK explicitly *want*
163
+ // shake to work in TestFlight / Play Store builds — that's the
164
+ // primary use case (a tester finds a bug in a release build and
165
+ // shakes to report it). Dev builds get shake too. Apps that want
166
+ // to disable shake pass `enabled: false` (or
167
+ // `disableShakeGesture: true` for finer-grained control).
159
168
  if (cfg.enabled !== undefined) {
160
169
  enabled = cfg.enabled;
161
170
  } else {
162
- enabled = typeof __DEV__ !== 'undefined' ? __DEV__ : false;
171
+ enabled = !cfg.disableShakeGesture;
163
172
  }
164
173
 
165
174
  // Hydrate cached auth token + preferred device from AsyncStorage so the
@@ -193,7 +202,7 @@ export class YaverFeedback {
193
202
  shakeDetector.stop();
194
203
  shakeDetector = null;
195
204
  }
196
- if (enabled && config.trigger === 'shake') {
205
+ if (enabled && config.trigger === 'shake' && !config.disableShakeGesture) {
197
206
  shakeDetector = new ShakeDetector();
198
207
  shakeDetector.start(() => {
199
208
  YaverFeedback.notifyShake();
@@ -253,18 +262,39 @@ export class YaverFeedback {
253
262
  });
254
263
  }
255
264
  });
256
- // NOTE: BlackBox.start() is intentionally NOT auto-called here.
257
- // An earlier version (0.7.6) did auto-start it, and when the
258
- // agent was in bootstrap / needs-auth mode which can happen
259
- // any time after `yaver serve` restarts before the user pairs
260
- // the SSE channel retried with exponential backoff on 401s,
261
- // producing a tight loop of string concatenation + JSON parse
262
- // that tripped a Hermes rope-string SIGSEGV on iOS 18.3.1
263
- // during any other JS-thread regex work (e.g. react-native-
264
- // view-shot's internal string handling during Screenshot &
265
- // Fix). Host apps call BlackBox.start() explicitly once they
266
- // know the agent URL + token are valid (SFMG does this inside
267
- // its YaverFeedbackWidget after auth).
265
+ // BlackBox auto-start (0.8.8+).
266
+ //
267
+ // 0.7.6 auto-started BlackBox immediately, which produced a
268
+ // Hermes rope-string SIGSEGV on iOS 18.3.1 when the agent was in
269
+ // bootstrap / needs-auth mode: the SSE channel retried with
270
+ // exponential backoff on 401s, generating a tight string-concat
271
+ // + JSON-parse loop that collided with react-native-view-shot's
272
+ // internal string handling during Screenshot & Fix. We rolled it
273
+ // back to manual-start (host calls BlackBox.start() after auth).
274
+ //
275
+ // The fix that lets us auto-start safely now:
276
+ // 1. Defer the start by 500ms so init() returns, the JS bridge
277
+ // settles, and any first-launch auth-token round trip on
278
+ // another thread completes before SSE opens.
279
+ // 2. Only start when we have BOTH an agentUrl AND an authToken
280
+ // — without the token, the connect() call would 401 and we'd
281
+ // reproduce the original loop.
282
+ // 3. Caller can opt out with cfg.autoStartBlackBox = false.
283
+ //
284
+ // SFMG used to call BlackBox.start() inside YaverFeedbackWidget
285
+ // after auth — that path still works (start() is idempotent), so
286
+ // upgrading SDK without removing the manual call is safe.
287
+ if (cfg.autoStartBlackBox !== false) {
288
+ setTimeout(() => {
289
+ if (config?.agentUrl && (config?.authToken || p2pAuthToken)) {
290
+ try {
291
+ BlackBox.start();
292
+ } catch (err) {
293
+ console.warn('[YaverFeedback] BlackBox auto-start failed:', err);
294
+ }
295
+ }
296
+ }, 500);
297
+ }
268
298
  }
269
299
 
270
300
  // NOTE: We intentionally do NOT hook ErrorUtils.setGlobalHandler().
@@ -583,7 +613,7 @@ export class YaverFeedback {
583
613
  BlackBox.start(); // restart with previous config
584
614
  }
585
615
  // Restart shake detector if trigger is 'shake'
586
- if (config?.trigger === 'shake' && !shakeDetector) {
616
+ if (config?.trigger === 'shake' && !config?.disableShakeGesture && !shakeDetector) {
587
617
  shakeDetector = new ShakeDetector();
588
618
  shakeDetector.start(() => {
589
619
  YaverFeedback.notifyShake();
@@ -31,10 +31,10 @@ describe('auth device listing', () => {
31
31
  platform: 'linux',
32
32
  isOnline: true,
33
33
  isGuest: true,
34
- hostName: 'Kivanc Cakmak',
35
- hostEmail: 'kivanc.cakmak@icloud.com',
34
+ hostName: 'Host User',
35
+ hostEmail: 'host@example.com',
36
36
  accessScope: 'shared-scoped',
37
- quicHost: '157.180.114.179',
37
+ quicHost: '198.51.100.20',
38
38
  quicPort: 18080,
39
39
  lastHeartbeat: 456,
40
40
  },
@@ -56,7 +56,7 @@ describe('auth device listing', () => {
56
56
  expect(result.shared[0]).toMatchObject({
57
57
  deviceId: 'guest-1',
58
58
  isGuest: true,
59
- hostEmail: 'kivanc.cakmak@icloud.com',
59
+ hostEmail: 'host@example.com',
60
60
  accessScope: 'shared-scoped',
61
61
  });
62
62
  });
@@ -73,10 +73,10 @@ describe('auth device listing', () => {
73
73
  platform: 'linux',
74
74
  isOnline: true,
75
75
  isGuest: true,
76
- hostName: 'Kivanc Cakmak',
77
- hostEmail: 'kivanc.cakmak@icloud.com',
76
+ hostName: 'Host User',
77
+ hostEmail: 'host@example.com',
78
78
  accessScope: 'shared-scoped',
79
- quicHost: '157.180.114.179',
79
+ quicHost: '198.51.100.20',
80
80
  quicPort: 18080,
81
81
  lastHeartbeat: 789,
82
82
  },
@@ -0,0 +1,226 @@
1
+ // Hot-reload command-channel coverage for BlackBox.
2
+ //
3
+ // The agent uses BlackBox's SSE channel as a back-channel to push
4
+ // "reload" / "reload_bundle" commands into a running guest app
5
+ // (see desktop/agent/blackbox.go::BroadcastCommand). This test
6
+ // pins the SDK's contract:
7
+ //
8
+ // 1. onCommand(handler) registers a handler and returns an
9
+ // unsubscribe function that actually unsubscribes.
10
+ // 2. start() opens the SSE command-stream against the agent
11
+ // with the proper URL + headers.
12
+ // 3. When the SSE stream delivers a JSON message of the form
13
+ // {type:"command", command:{command:"reload", data:...}},
14
+ // the registered handler fires with the inner command.
15
+ // 4. start() also schedules the periodic flush — proving start
16
+ // is idempotent over multiple calls.
17
+
18
+ import { BlackBox } from '../BlackBox';
19
+ import { YaverFeedback } from '../YaverFeedback';
20
+
21
+ jest.mock('react-native', () => ({
22
+ Platform: { OS: 'ios' },
23
+ }));
24
+
25
+ // Drive Date.now / setTimeout deterministically.
26
+ jest.useFakeTimers();
27
+
28
+ const mockFetch = jest.fn();
29
+ global.fetch = mockFetch as any;
30
+
31
+ class MockAbortController {
32
+ signal = { aborted: false };
33
+ abort = jest.fn(() => {
34
+ this.signal.aborted = true;
35
+ });
36
+ }
37
+ global.AbortController = MockAbortController as any;
38
+
39
+ // Helper to build an SSE streaming body.
40
+ function sseBody(messages: Array<unknown>): ReadableStream<Uint8Array> {
41
+ const enc = new TextEncoder();
42
+ const chunks = messages.map((m) => `data: ${JSON.stringify(m)}\n\n`);
43
+ return new ReadableStream<Uint8Array>({
44
+ start(controller) {
45
+ for (const c of chunks) controller.enqueue(enc.encode(c));
46
+ controller.close();
47
+ },
48
+ });
49
+ }
50
+
51
+ beforeEach(() => {
52
+ jest.clearAllMocks();
53
+ // Default: every POST (events) and the SSE GET both succeed.
54
+ mockFetch.mockImplementation((url: string) => {
55
+ if (url.includes('/blackbox/command-stream')) {
56
+ return Promise.resolve({
57
+ ok: true,
58
+ body: sseBody([]),
59
+ });
60
+ }
61
+ return Promise.resolve({
62
+ ok: true,
63
+ json: () => Promise.resolve({}),
64
+ });
65
+ });
66
+ YaverFeedback.init({
67
+ agentUrl: 'http://localhost:18080',
68
+ authToken: 'tok',
69
+ });
70
+ });
71
+
72
+ afterEach(() => {
73
+ BlackBox.stop();
74
+ });
75
+
76
+ describe('BlackBox hot-reload command channel', () => {
77
+ it('onCommand returns an unsubscribe that removes the handler', () => {
78
+ const a = jest.fn();
79
+ const b = jest.fn();
80
+ const offA = BlackBox.onCommand(a);
81
+ const offB = BlackBox.onCommand(b);
82
+
83
+ // Both subscribed — calling internal dispatch directly via the
84
+ // public surface isn't easy without SSE mockery, but
85
+ // deregistering should still leave only `b` registered after
86
+ // offA(), then no handlers after offB().
87
+ offA();
88
+ offB();
89
+ // Re-subscribe and confirm subscription returns a fresh unsub.
90
+ const offC = BlackBox.onCommand(jest.fn());
91
+ expect(typeof offC).toBe('function');
92
+ offC();
93
+ });
94
+
95
+ it('start() opens SSE against /blackbox/command-stream with bearer + device headers', async () => {
96
+ BlackBox.start({ deviceId: 'dev-abc', appName: 'sfmg' });
97
+ // Allow the async fetch in connectSSE to fire.
98
+ await Promise.resolve();
99
+ await Promise.resolve();
100
+
101
+ const sseCall = mockFetch.mock.calls.find(([url]) =>
102
+ String(url).includes('/blackbox/command-stream'),
103
+ );
104
+ expect(sseCall).toBeDefined();
105
+ const [url, init] = sseCall!;
106
+ expect(String(url)).toContain('http://localhost:18080/blackbox/command-stream');
107
+ expect(String(url)).toContain('device=dev-abc');
108
+ expect(init.headers).toEqual(
109
+ expect.objectContaining({
110
+ Authorization: 'Bearer tok',
111
+ Accept: 'text/event-stream',
112
+ 'X-Device-ID': 'dev-abc',
113
+ 'X-App-Name': 'sfmg',
114
+ }),
115
+ );
116
+ });
117
+
118
+ it('dispatches a {type:"command", command:{command:"reload"}} SSE frame to handlers', async () => {
119
+ // SSE body delivers one reload command, then closes.
120
+ mockFetch.mockImplementation((url: string) => {
121
+ if (url.includes('/blackbox/command-stream')) {
122
+ return Promise.resolve({
123
+ ok: true,
124
+ body: sseBody([
125
+ { type: 'command', command: { command: 'reload', data: {} } },
126
+ ]),
127
+ });
128
+ }
129
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
130
+ });
131
+
132
+ const seen: any[] = [];
133
+ BlackBox.onCommand((cmd) => seen.push(cmd));
134
+ BlackBox.start({ deviceId: 'dev-1', appName: 'sfmg' });
135
+
136
+ // Drain the microtask + reader queue. The reader is async but
137
+ // the body is fully buffered + the stream closes immediately,
138
+ // so a few microtask flushes are enough.
139
+ for (let i = 0; i < 10; i++) await Promise.resolve();
140
+
141
+ expect(seen).toEqual([
142
+ expect.objectContaining({ command: 'reload', data: {} }),
143
+ ]);
144
+ });
145
+
146
+ it('dispatches a reload_bundle command (carries bundleUrl payload)', async () => {
147
+ mockFetch.mockImplementation((url: string) => {
148
+ if (url.includes('/blackbox/command-stream')) {
149
+ return Promise.resolve({
150
+ ok: true,
151
+ body: sseBody([
152
+ {
153
+ type: 'command',
154
+ command: {
155
+ command: 'reload_bundle',
156
+ data: { bundleUrl: '/dev/native-bundle' },
157
+ },
158
+ },
159
+ ]),
160
+ });
161
+ }
162
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
163
+ });
164
+
165
+ const seen: any[] = [];
166
+ BlackBox.onCommand((cmd) => seen.push(cmd));
167
+ BlackBox.start({ deviceId: 'dev-1', appName: 'sfmg' });
168
+ for (let i = 0; i < 10; i++) await Promise.resolve();
169
+
170
+ expect(seen).toEqual([
171
+ expect.objectContaining({
172
+ command: 'reload_bundle',
173
+ data: expect.objectContaining({ bundleUrl: '/dev/native-bundle' }),
174
+ }),
175
+ ]);
176
+ });
177
+
178
+ it('handler exception does not break sibling handlers', async () => {
179
+ mockFetch.mockImplementation((url: string) => {
180
+ if (url.includes('/blackbox/command-stream')) {
181
+ return Promise.resolve({
182
+ ok: true,
183
+ body: sseBody([
184
+ { type: 'command', command: { command: 'reload' } },
185
+ ]),
186
+ });
187
+ }
188
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
189
+ });
190
+
191
+ const sibling = jest.fn();
192
+ BlackBox.onCommand(() => {
193
+ throw new Error('caller bug — should not break the dispatch loop');
194
+ });
195
+ BlackBox.onCommand(sibling);
196
+
197
+ BlackBox.start({ deviceId: 'dev-1', appName: 'sfmg' });
198
+ for (let i = 0; i < 10; i++) await Promise.resolve();
199
+
200
+ expect(sibling).toHaveBeenCalledWith(
201
+ expect.objectContaining({ command: 'reload' }),
202
+ );
203
+ });
204
+
205
+ it('ignores non-command SSE frames (events/log/whatever)', async () => {
206
+ mockFetch.mockImplementation((url: string) => {
207
+ if (url.includes('/blackbox/command-stream')) {
208
+ return Promise.resolve({
209
+ ok: true,
210
+ body: sseBody([
211
+ { type: 'log', logLine: 'hi' },
212
+ { type: 'lifecycle', message: 'started' },
213
+ { ping: 1 },
214
+ ]),
215
+ });
216
+ }
217
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
218
+ });
219
+
220
+ const handler = jest.fn();
221
+ BlackBox.onCommand(handler);
222
+ BlackBox.start({ deviceId: 'dev-1', appName: 'sfmg' });
223
+ for (let i = 0; i < 10; i++) await Promise.resolve();
224
+ expect(handler).not.toHaveBeenCalled();
225
+ });
226
+ });
@@ -254,5 +254,75 @@ describe('P2PClient', () => {
254
254
  }),
255
255
  );
256
256
  });
257
+
258
+ it('dev mode hits /dev/reload with bearer auth', async () => {
259
+ mockFetch.mockResolvedValue({
260
+ ok: true,
261
+ json: () => Promise.resolve({ ok: true }),
262
+ });
263
+ const client = new P2PClient('http://localhost:18080', 'tok');
264
+ await client.reloadApp('dev');
265
+ expect(mockFetch).toHaveBeenCalledWith(
266
+ 'http://localhost:18080/dev/reload',
267
+ expect.objectContaining({
268
+ method: 'POST',
269
+ headers: expect.objectContaining({ Authorization: 'Bearer tok' }),
270
+ }),
271
+ );
272
+ });
273
+
274
+ it('bundle mode hits /dev/reload-app with mode + projectName in body', async () => {
275
+ mockFetch.mockResolvedValue({
276
+ ok: true,
277
+ json: () => Promise.resolve({ ok: true }),
278
+ });
279
+ const client = new P2PClient('http://localhost:18080', 'tok');
280
+ await client.reloadApp('bundle', { projectName: 'sfmg' });
281
+ const [url, init] = mockFetch.mock.calls[0];
282
+ expect(url).toBe('http://localhost:18080/dev/reload-app');
283
+ expect(init.method).toBe('POST');
284
+ expect(JSON.parse(init.body as string)).toEqual(
285
+ expect.objectContaining({ mode: 'bundle', projectName: 'sfmg' }),
286
+ );
287
+ expect(init.headers).toEqual(
288
+ expect.objectContaining({
289
+ Authorization: 'Bearer tok',
290
+ 'Content-Type': 'application/json',
291
+ }),
292
+ );
293
+ });
294
+
295
+ it('dev mode falls back to /dev/reload-app when /dev/reload 4xxs', async () => {
296
+ // First call: /dev/reload 404. Second: /dev/reload-app 200.
297
+ mockFetch
298
+ .mockResolvedValueOnce({ ok: false, status: 404, json: () => Promise.resolve({}) })
299
+ .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ok: true }) });
300
+
301
+ const client = new P2PClient('http://localhost:18080', 'tok');
302
+ const result = await client.reloadApp('dev', { projectName: 'sfmg' });
303
+
304
+ expect(mockFetch).toHaveBeenCalledTimes(2);
305
+ expect(mockFetch.mock.calls[0][0]).toBe('http://localhost:18080/dev/reload');
306
+ expect(mockFetch.mock.calls[1][0]).toBe('http://localhost:18080/dev/reload-app');
307
+ expect(result.ok).toBe(true);
308
+ });
309
+
310
+ it('surfaces nativeChangesDetected so the host can prompt for rebuild', async () => {
311
+ mockFetch.mockResolvedValue({
312
+ ok: true,
313
+ json: () =>
314
+ Promise.resolve({
315
+ ok: true,
316
+ nativeChangesDetected: true,
317
+ changeClass: 'native_required',
318
+ }),
319
+ });
320
+ const client = new P2PClient('http://localhost:18080', 'tok');
321
+ const result = await client.reloadApp('dev');
322
+ expect(result.nativeChangesDetected).toBe(true);
323
+ expect(result.changeClass).toBe('native_required');
324
+ // Caller-visible message must distinguish native-required from JS-only.
325
+ expect(result.message).toMatch(/native|rebuild/i);
326
+ });
257
327
  });
258
328
  });
@@ -83,6 +83,17 @@ describe('YaverFeedback', () => {
83
83
  expect(cfg!.strictNativeAuth).toBe(true);
84
84
  });
85
85
 
86
+ it('promotes quick icon to always when shake is disabled', () => {
87
+ YaverFeedback.init({
88
+ authToken: 'tok',
89
+ disableShakeGesture: true,
90
+ });
91
+
92
+ const cfg = YaverFeedback.getConfig();
93
+ expect(cfg!.disableShakeGesture).toBe(true);
94
+ expect(cfg!.quickIcon).toBe('always');
95
+ });
96
+
86
97
  it('with enabled=false sets enabled to false', () => {
87
98
  YaverFeedback.init({
88
99
  authToken: 'tok',
@@ -27,11 +27,13 @@ describe('React Native SDK types', () => {
27
27
  authToken: 'tok',
28
28
  agentUrl: 'http://192.168.1.10:18080',
29
29
  trigger: 'shake',
30
+ disableShakeGesture: true,
30
31
  enabled: true,
31
32
  maxRecordingDuration: 60,
32
33
  strictNativeAuth: true,
33
34
  };
34
35
  expect(config.trigger).toBe('shake');
36
+ expect(config.disableShakeGesture).toBe(true);
35
37
  expect(config.strictNativeAuth).toBe(true);
36
38
  });
37
39