yaver-feedback-react-native 0.8.7 → 0.8.9

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.
@@ -27,9 +27,13 @@ export declare const DEFAULT_BEACON_UDP_PORT = 19837;
27
27
  /**
28
28
  * How old an agent's last heartbeat can be before the device is
29
29
  * considered offline. Mirrors `backend/convex/devices.ts` so
30
- * Convex + every client agree on the same threshold.
30
+ * Convex + every client agree on the same threshold. The agent
31
+ * heartbeats every 5 min (see `desktop/agent/main.go::heartbeatLoop`),
32
+ * so 6 min tolerates one missed beat + 60 s of jitter without
33
+ * flapping. Sub-minute death detection comes from the P2P bus, not
34
+ * this threshold.
31
35
  */
32
- export declare const HEARTBEAT_STALE_MS = 90000;
36
+ export declare const HEARTBEAT_STALE_MS = 360000;
33
37
  /**
34
38
  * How long after the last UDP beacon an agent is still considered
35
39
  * "locally present". Re-broadcast interval is 3 s, so 10 s covers
@@ -51,9 +51,13 @@ exports.DEFAULT_BEACON_UDP_PORT = 19837;
51
51
  /**
52
52
  * How old an agent's last heartbeat can be before the device is
53
53
  * considered offline. Mirrors `backend/convex/devices.ts` so
54
- * Convex + every client agree on the same threshold.
54
+ * Convex + every client agree on the same threshold. The agent
55
+ * heartbeats every 5 min (see `desktop/agent/main.go::heartbeatLoop`),
56
+ * so 6 min tolerates one missed beat + 60 s of jitter without
57
+ * flapping. Sub-minute death detection comes from the P2P bus, not
58
+ * this threshold.
55
59
  */
56
- exports.HEARTBEAT_STALE_MS = 90000;
60
+ exports.HEARTBEAT_STALE_MS = 360000;
57
61
  /**
58
62
  * How long after the last UDP beacon an agent is still considered
59
63
  * "locally present". Re-broadcast interval is 3 s, so 10 s covers
package/dist/types.d.ts CHANGED
@@ -118,6 +118,35 @@ export interface FeedbackConfig {
118
118
  preferredDeviceId?: string;
119
119
  /** How feedback collection is triggered */
120
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;
121
150
  /**
122
151
  * Small tap-to-open icon that floats above the app so the user
123
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.7",
3
+ "version": "0.8.9",
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;
@@ -28,10 +28,13 @@ import {
28
28
  // (mobile/ios/Yaver/YaverInfo.{swift,m} + Android counterpart); a
29
29
  // standalone app bundled by its own developer has no such module.
30
30
  // When the SDK is loaded through Yaver's Hermes-push guest runtime we
31
- // deliberately no-op every public entry point Yaver owns the shake
32
- // gesture ("Reload / Back to Yaver" overlay), the feedback capture
33
- // flow, and the BlackBox streaming; running a second copy from inside
34
- // the guest just produces duplicate UIs and double P2P sessions.
31
+ // run in HOST MODE: dormant by default (no shake detector, no auto
32
+ // BlackBox, no QuickActionIcon Yaver's host shell owns those), but
33
+ // we DO register a DeviceEventEmitter listener so Yaver's overlay can
34
+ // flip the SDK live at runtime. When the user shakes inside the guest
35
+ // app and taps "Feedback" on the Yaver overlay, AppDelegate dispatches
36
+ // `yaverFeedback:startReport` into this bridge; the listener wakes the
37
+ // SDK and opens the modal in-place over the running guest UI.
35
38
  function isRunningInsideYaverHost(): boolean {
36
39
  try {
37
40
  return !!(NativeModules as any)?.YaverInfo;
@@ -40,11 +43,105 @@ function isRunningInsideYaverHost(): boolean {
40
43
  }
41
44
  }
42
45
 
43
- // Suppresses SDK activation when inside Yaver super-host. Callers of
44
- // YaverFeedback.init / startReport / startBatchRecording / … early-out
45
- // by checking this first so the SDK's side effects (accelerometer,
46
- // BlackBox HTTP, SSE command channel, FeedbackModal mount) never start.
47
- const YAVER_HOST_SUPPRESS = isRunningInsideYaverHost();
46
+ // Two distinct compile-time modes for a guest app like sfmg / talos:
47
+ //
48
+ // YAVER_HOST_MODE — bundled by Yaver's agent (/dev/build-native) for
49
+ // loading inside Yaver mobile. SDK code is in the
50
+ // bundle but boots PASSIVE: no shake detector, no
51
+ // auto-BlackBox, no container UI. Yaver's host
52
+ // overlay owns the shake gesture; when the user
53
+ // taps "Feedback" on Yaver's overlay, AppDelegate
54
+ // dispatches yaverFeedback:hostActivate into this
55
+ // bridge and the SDK runtime-flips active for a
56
+ // single feedback session.
57
+ //
58
+ // YAVER_SDK_MODE — sfmg's own standalone TestFlight / Play Store
59
+ // build, with the Yaver SDK embedded. SDK boots
60
+ // active: shake → modal directly, no Yaver host
61
+ // involved. Default for normal `expo build`.
62
+ //
63
+ // Both can be forced at build time via process.env. When neither is
64
+ // set, fall back to runtime detection: if the YaverInfo native module
65
+ // exists (we're inside Yaver), assume HOST_MODE; else SDK_MODE. This
66
+ // keeps older bundles (built before the agent learned to set the env)
67
+ // working unchanged.
68
+ const YAVER_HOST_MODE_BUILD = (() => {
69
+ try {
70
+ const v = (process.env as any)?.YAVER_HOST_MODE;
71
+ return v === 'true' || v === '1' || v === true || v === 1;
72
+ } catch { return false; }
73
+ })();
74
+ const YAVER_SDK_MODE_BUILD = (() => {
75
+ try {
76
+ const v = (process.env as any)?.YAVER_SDK_MODE;
77
+ return v === 'true' || v === '1' || v === true || v === 1;
78
+ } catch { return false; }
79
+ })();
80
+
81
+ // Effective mode after considering build flags AND runtime detection.
82
+ const IS_HOST_MODE =
83
+ YAVER_HOST_MODE_BUILD ||
84
+ (!YAVER_SDK_MODE_BUILD && isRunningInsideYaverHost());
85
+
86
+ // Tracks whether we've been runtime-activated by the host (sfmg-in-Yaver
87
+ // case). Independent of `enabled` so we can tell "host turned us on for
88
+ // one shot" apart from "developer toggled enabled programmatically".
89
+ let hostActivated = false;
90
+
91
+ // Host-activation listener. Always registered when in HOST mode so a
92
+ // Yaver overlay tap can wake the SDK even before the guest's
93
+ // YaverFeedback.init() runs. AppDelegate (mobile/ios/Yaver/AppDelegate.
94
+ // swift::handleFeedbackTap) sends `yaverFeedback:startReport` into the
95
+ // guest bridge when the user picks Feedback on the shake overlay.
96
+ //
97
+ // Activation flow:
98
+ // 1. Try Yaver's existing bearer (NativeModules.YaverInfo.
99
+ // inheritedAuthToken, populated by Yaver mobile's auth.ts on
100
+ // sign-in). Validate against /auth/validate before trusting.
101
+ // 2. If valid: setAuthToken + open feedback modal in-place (the
102
+ // modal already supports hot reload, screenshot, vibing chat).
103
+ // 3. If missing or invalid: open the SDK's own login screen.
104
+ if (IS_HOST_MODE) {
105
+ try {
106
+ const { DeviceEventEmitter } = require('react-native');
107
+ DeviceEventEmitter.addListener('yaverFeedback:startReport', () => {
108
+ hostActivated = true;
109
+ enabled = true;
110
+ void (async () => {
111
+ const yi = (NativeModules as any)?.YaverInfo;
112
+ const inheritedToken = String(yi?.inheritedAuthToken || '').trim();
113
+ const inheritedAgent = String(yi?.inheritedAgentUrl || '').trim();
114
+ const inheritedDevice = String(yi?.inheritedDeviceId || '').trim();
115
+ if (inheritedToken) {
116
+ // Lazy-import auth.ts so module load doesn't drag the auth
117
+ // network code into the active set when the SDK is dormant.
118
+ const { validateToken } = require('./auth');
119
+ const user = await validateToken(inheritedToken).catch(() => null);
120
+ if (user) {
121
+ // Seed config + connect the SDK to the host's auth.
122
+ if (!config) {
123
+ YaverFeedback.init({
124
+ authToken: inheritedToken,
125
+ agentUrl: inheritedAgent || undefined,
126
+ preferredDeviceId: inheritedDevice || undefined,
127
+ } as FeedbackConfig);
128
+ }
129
+ await YaverFeedback.setAuthToken(inheritedToken);
130
+ await YaverFeedback.startReport();
131
+ return;
132
+ }
133
+ }
134
+ // No token, or token invalid — fall through to the SDK's own
135
+ // login screen. The user picks an OAuth provider; on success
136
+ // the modal continues with the new token.
137
+ if (!config) {
138
+ YaverFeedback.init({} as FeedbackConfig);
139
+ }
140
+ YaverFeedback.showLogin();
141
+ })();
142
+ });
143
+ } catch { /* react-native unavailable in jsdom unit tests */ }
144
+ }
48
145
 
49
146
  let config: FeedbackConfig | null = null;
50
147
  let enabled = false;
@@ -126,17 +223,37 @@ export class YaverFeedback {
126
223
  * via `YaverDiscovery` on the first `startReport()` call.
127
224
  */
128
225
  static init(cfg: FeedbackConfig): void {
129
- if (YAVER_HOST_SUPPRESS) {
130
- // Running inside Yaver's super-host — yield to Yaver's native UX.
131
- enabled = false;
132
- return;
133
- }
134
226
  config = {
135
227
  trigger: 'shake',
136
228
  maxRecordingDuration: 120,
137
229
  autoLogin: true,
138
230
  ...cfg,
139
231
  };
232
+ if (IS_HOST_MODE) {
233
+ // sfmg / talos / etc. running inside Yaver mobile (compile-time
234
+ // YAVER_HOST_MODE or runtime-detected). Store config so a later
235
+ // host activation (yaverFeedback:startReport from AppDelegate)
236
+ // can open the modal — but skip the side effects Yaver's host
237
+ // shell owns: shake detector, auto-BlackBox, QuickActionIcon.
238
+ enabled = false;
239
+ // Configure auth endpoints + strict-native-auth even in passive
240
+ // mode so a host-activated session uses the same login routing
241
+ // the standalone path would.
242
+ configureAuthEndpoints({
243
+ convexSiteUrl: cfg.authConvexSiteUrl,
244
+ webBaseUrl: cfg.authWebBaseUrl,
245
+ });
246
+ setStrictNativeAuth(cfg.strictNativeAuth === true);
247
+ if (!config.convexUrl) {
248
+ config.convexUrl = cfg.authConvexSiteUrl ?? DEFAULT_CONVEX_SITE_URL;
249
+ }
250
+ maxErrors = cfg.maxCapturedErrors ?? 5;
251
+ errorBuffer = [];
252
+ return;
253
+ }
254
+ if (config.disableShakeGesture && (!config.quickIcon || config.quickIcon === 'auto')) {
255
+ config.quickIcon = 'always';
256
+ }
140
257
  firstShakeFired = false;
141
258
 
142
259
  // Route the in-SDK login screen to prod yaver.io by default; callers may
@@ -155,11 +272,17 @@ export class YaverFeedback {
155
272
  config.convexUrl = cfg.authConvexSiteUrl ?? DEFAULT_CONVEX_SITE_URL;
156
273
  }
157
274
 
158
- // Default: enabled only in dev mode
275
+ // Default: enabled. Pre-0.8.8 the SDK only enabled shake in dev
276
+ // builds (`__DEV__`), but apps that bundle the SDK explicitly *want*
277
+ // shake to work in TestFlight / Play Store builds — that's the
278
+ // primary use case (a tester finds a bug in a release build and
279
+ // shakes to report it). Dev builds get shake too. Apps that want
280
+ // to disable shake pass `enabled: false` (or
281
+ // `disableShakeGesture: true` for finer-grained control).
159
282
  if (cfg.enabled !== undefined) {
160
283
  enabled = cfg.enabled;
161
284
  } else {
162
- enabled = typeof __DEV__ !== 'undefined' ? __DEV__ : false;
285
+ enabled = !cfg.disableShakeGesture;
163
286
  }
164
287
 
165
288
  // Hydrate cached auth token + preferred device from AsyncStorage so the
@@ -193,7 +316,7 @@ export class YaverFeedback {
193
316
  shakeDetector.stop();
194
317
  shakeDetector = null;
195
318
  }
196
- if (enabled && config.trigger === 'shake') {
319
+ if (enabled && config.trigger === 'shake' && !config.disableShakeGesture) {
197
320
  shakeDetector = new ShakeDetector();
198
321
  shakeDetector.start(() => {
199
322
  YaverFeedback.notifyShake();
@@ -253,18 +376,39 @@ export class YaverFeedback {
253
376
  });
254
377
  }
255
378
  });
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).
379
+ // BlackBox auto-start (0.8.8+).
380
+ //
381
+ // 0.7.6 auto-started BlackBox immediately, which produced a
382
+ // Hermes rope-string SIGSEGV on iOS 18.3.1 when the agent was in
383
+ // bootstrap / needs-auth mode: the SSE channel retried with
384
+ // exponential backoff on 401s, generating a tight string-concat
385
+ // + JSON-parse loop that collided with react-native-view-shot's
386
+ // internal string handling during Screenshot & Fix. We rolled it
387
+ // back to manual-start (host calls BlackBox.start() after auth).
388
+ //
389
+ // The fix that lets us auto-start safely now:
390
+ // 1. Defer the start by 500ms so init() returns, the JS bridge
391
+ // settles, and any first-launch auth-token round trip on
392
+ // another thread completes before SSE opens.
393
+ // 2. Only start when we have BOTH an agentUrl AND an authToken
394
+ // — without the token, the connect() call would 401 and we'd
395
+ // reproduce the original loop.
396
+ // 3. Caller can opt out with cfg.autoStartBlackBox = false.
397
+ //
398
+ // SFMG used to call BlackBox.start() inside YaverFeedbackWidget
399
+ // after auth — that path still works (start() is idempotent), so
400
+ // upgrading SDK without removing the manual call is safe.
401
+ if (cfg.autoStartBlackBox !== false) {
402
+ setTimeout(() => {
403
+ if (config?.agentUrl && (config?.authToken || p2pAuthToken)) {
404
+ try {
405
+ BlackBox.start();
406
+ } catch (err) {
407
+ console.warn('[YaverFeedback] BlackBox auto-start failed:', err);
408
+ }
409
+ }
410
+ }, 500);
411
+ }
268
412
  }
269
413
 
270
414
  // NOTE: We intentionally do NOT hook ErrorUtils.setGlobalHandler().
@@ -583,7 +727,7 @@ export class YaverFeedback {
583
727
  BlackBox.start(); // restart with previous config
584
728
  }
585
729
  // Restart shake detector if trigger is 'shake'
586
- if (config?.trigger === 'shake' && !shakeDetector) {
730
+ if (config?.trigger === 'shake' && !config?.disableShakeGesture && !shakeDetector) {
587
731
  shakeDetector = new ShakeDetector();
588
732
  shakeDetector.start(() => {
589
733
  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
+ });