yaver-feedback-react-native 0.8.7 → 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/README.md +23 -0
- package/dist/MachinePickerScreen.js +2 -2
- package/dist/YaverFeedback.js +47 -16
- package/dist/__tests__/AuthDevices.test.js +7 -7
- package/dist/__tests__/BlackBox.hotReload.test.d.ts +1 -0
- package/dist/__tests__/BlackBox.hotReload.test.js +204 -0
- package/dist/__tests__/P2PClient.test.js +56 -0
- package/dist/__tests__/YaverFeedback.test.js +9 -0
- package/dist/__tests__/types.test.js +2 -0
- package/dist/_core/constants.d.ts +6 -2
- package/dist/_core/constants.js +6 -2
- package/dist/types.d.ts +29 -0
- package/package.json +1 -1
- package/src/MachinePickerScreen.tsx +2 -2
- package/src/YaverFeedback.ts +46 -16
- package/src/__tests__/AuthDevices.test.ts +7 -7
- package/src/__tests__/BlackBox.hotReload.test.ts +226 -0
- package/src/__tests__/P2PClient.test.ts +70 -0
- package/src/__tests__/YaverFeedback.test.ts +11 -0
- package/src/__tests__/types.test.ts +2 -0
- package/src/_core/constants.ts +6 -2
- package/src/types.ts +29 -0
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Visual feedback SDK for Yaver. Lets testers and developers shake their phone and then keep a small quick-access icon on screen for hot reload, vibing, and screenshot & fix flows straight to a Yaver agent running on a dev machine.
|
|
4
4
|
|
|
5
|
+
Haptics ownership rule: when your app is paired with Yaver, app-level haptics should belong to Yaver Feedback alone. If the host app also fires its own `expo-haptics` calls, you risk duplicated tactile feedback and a larger native-module crash surface during guest/runtime flows.
|
|
6
|
+
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -618,6 +620,7 @@ YaverFeedback.init({
|
|
|
618
620
|
// Optional
|
|
619
621
|
agentUrl: 'http://192.168.1.10:18080', // Agent URL (auto-discovered if omitted)
|
|
620
622
|
trigger: 'shake', // 'shake' | 'floating-button' | 'manual'
|
|
623
|
+
disableShakeGesture: false, // Non-default: disable shake + promote quick icon to 'always'
|
|
621
624
|
enabled: true, // Default: __DEV__ (auto-disabled in production)
|
|
622
625
|
maxRecordingDuration: 120, // Max recording duration in seconds (default: 120)
|
|
623
626
|
feedbackMode: 'batch', // 'live' | 'narrated' | 'batch' (default: 'batch')
|
|
@@ -662,6 +665,26 @@ SDK yields shake handling to Yaver's native overlay — see the
|
|
|
662
665
|
YaverFeedback.init({ authToken, trigger: 'shake' });
|
|
663
666
|
```
|
|
664
667
|
|
|
668
|
+
### No-shake build option
|
|
669
|
+
|
|
670
|
+
If another surface owns motion / haptics and you want the SDK without shake detection, enable the non-default `disableShakeGesture` option:
|
|
671
|
+
|
|
672
|
+
```typescript
|
|
673
|
+
YaverFeedback.init({
|
|
674
|
+
authToken,
|
|
675
|
+
trigger: 'shake',
|
|
676
|
+
disableShakeGesture: true,
|
|
677
|
+
});
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
Effects:
|
|
681
|
+
|
|
682
|
+
- `ShakeDetector` is not started.
|
|
683
|
+
- If `quickIcon` was unset / `'auto'`, the SDK promotes it to `'always'`.
|
|
684
|
+
- The user opens feedback through the draggable quick icon or your own manual trigger.
|
|
685
|
+
|
|
686
|
+
This is useful when Yaver Feedback should remain the only haptic/shake owner and the host app should not bind its own motion-triggered entry point.
|
|
687
|
+
|
|
665
688
|
### Floating Button
|
|
666
689
|
|
|
667
690
|
A small draggable "Y" button overlays the app. Tap to open the feedback modal.
|
|
@@ -121,8 +121,8 @@ const YaverMachinePickerScreen = ({ token, currentDeviceId, onPick, onCancel, })
|
|
|
121
121
|
// yellows from phone↔backend clock skew around the 89-90 s mark.
|
|
122
122
|
//
|
|
123
123
|
// `runnerDown` intentionally does NOT flip the dot. That flag
|
|
124
|
-
// tracks whether the AI runner (claude-code
|
|
125
|
-
// healthy — a separate concern from "can I reach this machine?"
|
|
124
|
+
// tracks whether the AI runner (claude-code / codex / opencode)
|
|
125
|
+
// is healthy — a separate concern from "can I reach this machine?"
|
|
126
126
|
// Mobile app surfaces runner issues via a separate badge, not
|
|
127
127
|
// this dot. Picker's job is reachability, nothing more.
|
|
128
128
|
const effectivelyReachable = probe?.reachable === true;
|
package/dist/YaverFeedback.js
CHANGED
|
@@ -113,6 +113,9 @@ class YaverFeedback {
|
|
|
113
113
|
autoLogin: true,
|
|
114
114
|
...cfg,
|
|
115
115
|
};
|
|
116
|
+
if (config.disableShakeGesture && (!config.quickIcon || config.quickIcon === 'auto')) {
|
|
117
|
+
config.quickIcon = 'always';
|
|
118
|
+
}
|
|
116
119
|
firstShakeFired = false;
|
|
117
120
|
// Route the in-SDK login screen to prod yaver.io by default; callers may
|
|
118
121
|
// override for staging via authConvexSiteUrl / authWebBaseUrl.
|
|
@@ -129,12 +132,18 @@ class YaverFeedback {
|
|
|
129
132
|
if (!config.convexUrl) {
|
|
130
133
|
config.convexUrl = cfg.authConvexSiteUrl ?? auth_1.DEFAULT_CONVEX_SITE_URL;
|
|
131
134
|
}
|
|
132
|
-
// Default: enabled only in dev
|
|
135
|
+
// Default: enabled. Pre-0.8.8 the SDK only enabled shake in dev
|
|
136
|
+
// builds (`__DEV__`), but apps that bundle the SDK explicitly *want*
|
|
137
|
+
// shake to work in TestFlight / Play Store builds — that's the
|
|
138
|
+
// primary use case (a tester finds a bug in a release build and
|
|
139
|
+
// shakes to report it). Dev builds get shake too. Apps that want
|
|
140
|
+
// to disable shake pass `enabled: false` (or
|
|
141
|
+
// `disableShakeGesture: true` for finer-grained control).
|
|
133
142
|
if (cfg.enabled !== undefined) {
|
|
134
143
|
enabled = cfg.enabled;
|
|
135
144
|
}
|
|
136
145
|
else {
|
|
137
|
-
enabled =
|
|
146
|
+
enabled = !cfg.disableShakeGesture;
|
|
138
147
|
}
|
|
139
148
|
// Hydrate cached auth token + preferred device from AsyncStorage so the
|
|
140
149
|
// SDK reconnects silently on subsequent launches. If autoLogin is false
|
|
@@ -165,7 +174,7 @@ class YaverFeedback {
|
|
|
165
174
|
shakeDetector.stop();
|
|
166
175
|
shakeDetector = null;
|
|
167
176
|
}
|
|
168
|
-
if (enabled && config.trigger === 'shake') {
|
|
177
|
+
if (enabled && config.trigger === 'shake' && !config.disableShakeGesture) {
|
|
169
178
|
shakeDetector = new ShakeDetector_1.ShakeDetector();
|
|
170
179
|
shakeDetector.start(() => {
|
|
171
180
|
YaverFeedback.notifyShake();
|
|
@@ -226,18 +235,40 @@ class YaverFeedback {
|
|
|
226
235
|
});
|
|
227
236
|
}
|
|
228
237
|
});
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// the SSE channel retried with
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
238
|
+
// BlackBox auto-start (0.8.8+).
|
|
239
|
+
//
|
|
240
|
+
// 0.7.6 auto-started BlackBox immediately, which produced a
|
|
241
|
+
// Hermes rope-string SIGSEGV on iOS 18.3.1 when the agent was in
|
|
242
|
+
// bootstrap / needs-auth mode: the SSE channel retried with
|
|
243
|
+
// exponential backoff on 401s, generating a tight string-concat
|
|
244
|
+
// + JSON-parse loop that collided with react-native-view-shot's
|
|
245
|
+
// internal string handling during Screenshot & Fix. We rolled it
|
|
246
|
+
// back to manual-start (host calls BlackBox.start() after auth).
|
|
247
|
+
//
|
|
248
|
+
// The fix that lets us auto-start safely now:
|
|
249
|
+
// 1. Defer the start by 500ms so init() returns, the JS bridge
|
|
250
|
+
// settles, and any first-launch auth-token round trip on
|
|
251
|
+
// another thread completes before SSE opens.
|
|
252
|
+
// 2. Only start when we have BOTH an agentUrl AND an authToken
|
|
253
|
+
// — without the token, the connect() call would 401 and we'd
|
|
254
|
+
// reproduce the original loop.
|
|
255
|
+
// 3. Caller can opt out with cfg.autoStartBlackBox = false.
|
|
256
|
+
//
|
|
257
|
+
// SFMG used to call BlackBox.start() inside YaverFeedbackWidget
|
|
258
|
+
// after auth — that path still works (start() is idempotent), so
|
|
259
|
+
// upgrading SDK without removing the manual call is safe.
|
|
260
|
+
if (cfg.autoStartBlackBox !== false) {
|
|
261
|
+
setTimeout(() => {
|
|
262
|
+
if (config?.agentUrl && (config?.authToken || p2pAuthToken)) {
|
|
263
|
+
try {
|
|
264
|
+
BlackBox_1.BlackBox.start();
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
console.warn('[YaverFeedback] BlackBox auto-start failed:', err);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}, 500);
|
|
271
|
+
}
|
|
241
272
|
}
|
|
242
273
|
// NOTE: We intentionally do NOT hook ErrorUtils.setGlobalHandler().
|
|
243
274
|
// Sentry, Crashlytics, Bugsnag, and other tools all compete for that
|
|
@@ -552,7 +583,7 @@ class YaverFeedback {
|
|
|
552
583
|
BlackBox_1.BlackBox.start(); // restart with previous config
|
|
553
584
|
}
|
|
554
585
|
// Restart shake detector if trigger is 'shake'
|
|
555
|
-
if (config?.trigger === 'shake' && !shakeDetector) {
|
|
586
|
+
if (config?.trigger === 'shake' && !config?.disableShakeGesture && !shakeDetector) {
|
|
556
587
|
shakeDetector = new ShakeDetector_1.ShakeDetector();
|
|
557
588
|
shakeDetector.start(() => {
|
|
558
589
|
YaverFeedback.notifyShake();
|
|
@@ -29,10 +29,10 @@ describe('auth device listing', () => {
|
|
|
29
29
|
platform: 'linux',
|
|
30
30
|
isOnline: true,
|
|
31
31
|
isGuest: true,
|
|
32
|
-
hostName: '
|
|
33
|
-
hostEmail: '
|
|
32
|
+
hostName: 'Host User',
|
|
33
|
+
hostEmail: 'host@example.com',
|
|
34
34
|
accessScope: 'shared-scoped',
|
|
35
|
-
quicHost: '
|
|
35
|
+
quicHost: '198.51.100.20',
|
|
36
36
|
quicPort: 18080,
|
|
37
37
|
lastHeartbeat: 456,
|
|
38
38
|
},
|
|
@@ -49,7 +49,7 @@ describe('auth device listing', () => {
|
|
|
49
49
|
expect(result.shared[0]).toMatchObject({
|
|
50
50
|
deviceId: 'guest-1',
|
|
51
51
|
isGuest: true,
|
|
52
|
-
hostEmail: '
|
|
52
|
+
hostEmail: 'host@example.com',
|
|
53
53
|
accessScope: 'shared-scoped',
|
|
54
54
|
});
|
|
55
55
|
});
|
|
@@ -64,10 +64,10 @@ describe('auth device listing', () => {
|
|
|
64
64
|
platform: 'linux',
|
|
65
65
|
isOnline: true,
|
|
66
66
|
isGuest: true,
|
|
67
|
-
hostName: '
|
|
68
|
-
hostEmail: '
|
|
67
|
+
hostName: 'Host User',
|
|
68
|
+
hostEmail: 'host@example.com',
|
|
69
69
|
accessScope: 'shared-scoped',
|
|
70
|
-
quicHost: '
|
|
70
|
+
quicHost: '198.51.100.20',
|
|
71
71
|
quicPort: 18080,
|
|
72
72
|
lastHeartbeat: 789,
|
|
73
73
|
},
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Hot-reload command-channel coverage for BlackBox.
|
|
3
|
+
//
|
|
4
|
+
// The agent uses BlackBox's SSE channel as a back-channel to push
|
|
5
|
+
// "reload" / "reload_bundle" commands into a running guest app
|
|
6
|
+
// (see desktop/agent/blackbox.go::BroadcastCommand). This test
|
|
7
|
+
// pins the SDK's contract:
|
|
8
|
+
//
|
|
9
|
+
// 1. onCommand(handler) registers a handler and returns an
|
|
10
|
+
// unsubscribe function that actually unsubscribes.
|
|
11
|
+
// 2. start() opens the SSE command-stream against the agent
|
|
12
|
+
// with the proper URL + headers.
|
|
13
|
+
// 3. When the SSE stream delivers a JSON message of the form
|
|
14
|
+
// {type:"command", command:{command:"reload", data:...}},
|
|
15
|
+
// the registered handler fires with the inner command.
|
|
16
|
+
// 4. start() also schedules the periodic flush — proving start
|
|
17
|
+
// is idempotent over multiple calls.
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
const BlackBox_1 = require("../BlackBox");
|
|
20
|
+
const YaverFeedback_1 = require("../YaverFeedback");
|
|
21
|
+
jest.mock('react-native', () => ({
|
|
22
|
+
Platform: { OS: 'ios' },
|
|
23
|
+
}));
|
|
24
|
+
// Drive Date.now / setTimeout deterministically.
|
|
25
|
+
jest.useFakeTimers();
|
|
26
|
+
const mockFetch = jest.fn();
|
|
27
|
+
global.fetch = mockFetch;
|
|
28
|
+
class MockAbortController {
|
|
29
|
+
constructor() {
|
|
30
|
+
this.signal = { aborted: false };
|
|
31
|
+
this.abort = jest.fn(() => {
|
|
32
|
+
this.signal.aborted = true;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
global.AbortController = MockAbortController;
|
|
37
|
+
// Helper to build an SSE streaming body.
|
|
38
|
+
function sseBody(messages) {
|
|
39
|
+
const enc = new TextEncoder();
|
|
40
|
+
const chunks = messages.map((m) => `data: ${JSON.stringify(m)}\n\n`);
|
|
41
|
+
return new ReadableStream({
|
|
42
|
+
start(controller) {
|
|
43
|
+
for (const c of chunks)
|
|
44
|
+
controller.enqueue(enc.encode(c));
|
|
45
|
+
controller.close();
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
jest.clearAllMocks();
|
|
51
|
+
// Default: every POST (events) and the SSE GET both succeed.
|
|
52
|
+
mockFetch.mockImplementation((url) => {
|
|
53
|
+
if (url.includes('/blackbox/command-stream')) {
|
|
54
|
+
return Promise.resolve({
|
|
55
|
+
ok: true,
|
|
56
|
+
body: sseBody([]),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return Promise.resolve({
|
|
60
|
+
ok: true,
|
|
61
|
+
json: () => Promise.resolve({}),
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
YaverFeedback_1.YaverFeedback.init({
|
|
65
|
+
agentUrl: 'http://localhost:18080',
|
|
66
|
+
authToken: 'tok',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
BlackBox_1.BlackBox.stop();
|
|
71
|
+
});
|
|
72
|
+
describe('BlackBox hot-reload command channel', () => {
|
|
73
|
+
it('onCommand returns an unsubscribe that removes the handler', () => {
|
|
74
|
+
const a = jest.fn();
|
|
75
|
+
const b = jest.fn();
|
|
76
|
+
const offA = BlackBox_1.BlackBox.onCommand(a);
|
|
77
|
+
const offB = BlackBox_1.BlackBox.onCommand(b);
|
|
78
|
+
// Both subscribed — calling internal dispatch directly via the
|
|
79
|
+
// public surface isn't easy without SSE mockery, but
|
|
80
|
+
// deregistering should still leave only `b` registered after
|
|
81
|
+
// offA(), then no handlers after offB().
|
|
82
|
+
offA();
|
|
83
|
+
offB();
|
|
84
|
+
// Re-subscribe and confirm subscription returns a fresh unsub.
|
|
85
|
+
const offC = BlackBox_1.BlackBox.onCommand(jest.fn());
|
|
86
|
+
expect(typeof offC).toBe('function');
|
|
87
|
+
offC();
|
|
88
|
+
});
|
|
89
|
+
it('start() opens SSE against /blackbox/command-stream with bearer + device headers', async () => {
|
|
90
|
+
BlackBox_1.BlackBox.start({ deviceId: 'dev-abc', appName: 'sfmg' });
|
|
91
|
+
// Allow the async fetch in connectSSE to fire.
|
|
92
|
+
await Promise.resolve();
|
|
93
|
+
await Promise.resolve();
|
|
94
|
+
const sseCall = mockFetch.mock.calls.find(([url]) => String(url).includes('/blackbox/command-stream'));
|
|
95
|
+
expect(sseCall).toBeDefined();
|
|
96
|
+
const [url, init] = sseCall;
|
|
97
|
+
expect(String(url)).toContain('http://localhost:18080/blackbox/command-stream');
|
|
98
|
+
expect(String(url)).toContain('device=dev-abc');
|
|
99
|
+
expect(init.headers).toEqual(expect.objectContaining({
|
|
100
|
+
Authorization: 'Bearer tok',
|
|
101
|
+
Accept: 'text/event-stream',
|
|
102
|
+
'X-Device-ID': 'dev-abc',
|
|
103
|
+
'X-App-Name': 'sfmg',
|
|
104
|
+
}));
|
|
105
|
+
});
|
|
106
|
+
it('dispatches a {type:"command", command:{command:"reload"}} SSE frame to handlers', async () => {
|
|
107
|
+
// SSE body delivers one reload command, then closes.
|
|
108
|
+
mockFetch.mockImplementation((url) => {
|
|
109
|
+
if (url.includes('/blackbox/command-stream')) {
|
|
110
|
+
return Promise.resolve({
|
|
111
|
+
ok: true,
|
|
112
|
+
body: sseBody([
|
|
113
|
+
{ type: 'command', command: { command: 'reload', data: {} } },
|
|
114
|
+
]),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
|
118
|
+
});
|
|
119
|
+
const seen = [];
|
|
120
|
+
BlackBox_1.BlackBox.onCommand((cmd) => seen.push(cmd));
|
|
121
|
+
BlackBox_1.BlackBox.start({ deviceId: 'dev-1', appName: 'sfmg' });
|
|
122
|
+
// Drain the microtask + reader queue. The reader is async but
|
|
123
|
+
// the body is fully buffered + the stream closes immediately,
|
|
124
|
+
// so a few microtask flushes are enough.
|
|
125
|
+
for (let i = 0; i < 10; i++)
|
|
126
|
+
await Promise.resolve();
|
|
127
|
+
expect(seen).toEqual([
|
|
128
|
+
expect.objectContaining({ command: 'reload', data: {} }),
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
it('dispatches a reload_bundle command (carries bundleUrl payload)', async () => {
|
|
132
|
+
mockFetch.mockImplementation((url) => {
|
|
133
|
+
if (url.includes('/blackbox/command-stream')) {
|
|
134
|
+
return Promise.resolve({
|
|
135
|
+
ok: true,
|
|
136
|
+
body: sseBody([
|
|
137
|
+
{
|
|
138
|
+
type: 'command',
|
|
139
|
+
command: {
|
|
140
|
+
command: 'reload_bundle',
|
|
141
|
+
data: { bundleUrl: '/dev/native-bundle' },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
]),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
|
148
|
+
});
|
|
149
|
+
const seen = [];
|
|
150
|
+
BlackBox_1.BlackBox.onCommand((cmd) => seen.push(cmd));
|
|
151
|
+
BlackBox_1.BlackBox.start({ deviceId: 'dev-1', appName: 'sfmg' });
|
|
152
|
+
for (let i = 0; i < 10; i++)
|
|
153
|
+
await Promise.resolve();
|
|
154
|
+
expect(seen).toEqual([
|
|
155
|
+
expect.objectContaining({
|
|
156
|
+
command: 'reload_bundle',
|
|
157
|
+
data: expect.objectContaining({ bundleUrl: '/dev/native-bundle' }),
|
|
158
|
+
}),
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
it('handler exception does not break sibling handlers', async () => {
|
|
162
|
+
mockFetch.mockImplementation((url) => {
|
|
163
|
+
if (url.includes('/blackbox/command-stream')) {
|
|
164
|
+
return Promise.resolve({
|
|
165
|
+
ok: true,
|
|
166
|
+
body: sseBody([
|
|
167
|
+
{ type: 'command', command: { command: 'reload' } },
|
|
168
|
+
]),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
|
172
|
+
});
|
|
173
|
+
const sibling = jest.fn();
|
|
174
|
+
BlackBox_1.BlackBox.onCommand(() => {
|
|
175
|
+
throw new Error('caller bug — should not break the dispatch loop');
|
|
176
|
+
});
|
|
177
|
+
BlackBox_1.BlackBox.onCommand(sibling);
|
|
178
|
+
BlackBox_1.BlackBox.start({ deviceId: 'dev-1', appName: 'sfmg' });
|
|
179
|
+
for (let i = 0; i < 10; i++)
|
|
180
|
+
await Promise.resolve();
|
|
181
|
+
expect(sibling).toHaveBeenCalledWith(expect.objectContaining({ command: 'reload' }));
|
|
182
|
+
});
|
|
183
|
+
it('ignores non-command SSE frames (events/log/whatever)', async () => {
|
|
184
|
+
mockFetch.mockImplementation((url) => {
|
|
185
|
+
if (url.includes('/blackbox/command-stream')) {
|
|
186
|
+
return Promise.resolve({
|
|
187
|
+
ok: true,
|
|
188
|
+
body: sseBody([
|
|
189
|
+
{ type: 'log', logLine: 'hi' },
|
|
190
|
+
{ type: 'lifecycle', message: 'started' },
|
|
191
|
+
{ ping: 1 },
|
|
192
|
+
]),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
|
196
|
+
});
|
|
197
|
+
const handler = jest.fn();
|
|
198
|
+
BlackBox_1.BlackBox.onCommand(handler);
|
|
199
|
+
BlackBox_1.BlackBox.start({ deviceId: 'dev-1', appName: 'sfmg' });
|
|
200
|
+
for (let i = 0; i < 10; i++)
|
|
201
|
+
await Promise.resolve();
|
|
202
|
+
expect(handler).not.toHaveBeenCalled();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -195,5 +195,61 @@ describe('P2PClient', () => {
|
|
|
195
195
|
message: 'Reload request acknowledged. Agent is rebuilding the bundle.',
|
|
196
196
|
}));
|
|
197
197
|
});
|
|
198
|
+
it('dev mode hits /dev/reload with bearer auth', async () => {
|
|
199
|
+
mockFetch.mockResolvedValue({
|
|
200
|
+
ok: true,
|
|
201
|
+
json: () => Promise.resolve({ ok: true }),
|
|
202
|
+
});
|
|
203
|
+
const client = new P2PClient_1.P2PClient('http://localhost:18080', 'tok');
|
|
204
|
+
await client.reloadApp('dev');
|
|
205
|
+
expect(mockFetch).toHaveBeenCalledWith('http://localhost:18080/dev/reload', expect.objectContaining({
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: expect.objectContaining({ Authorization: 'Bearer tok' }),
|
|
208
|
+
}));
|
|
209
|
+
});
|
|
210
|
+
it('bundle mode hits /dev/reload-app with mode + projectName in body', async () => {
|
|
211
|
+
mockFetch.mockResolvedValue({
|
|
212
|
+
ok: true,
|
|
213
|
+
json: () => Promise.resolve({ ok: true }),
|
|
214
|
+
});
|
|
215
|
+
const client = new P2PClient_1.P2PClient('http://localhost:18080', 'tok');
|
|
216
|
+
await client.reloadApp('bundle', { projectName: 'sfmg' });
|
|
217
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
218
|
+
expect(url).toBe('http://localhost:18080/dev/reload-app');
|
|
219
|
+
expect(init.method).toBe('POST');
|
|
220
|
+
expect(JSON.parse(init.body)).toEqual(expect.objectContaining({ mode: 'bundle', projectName: 'sfmg' }));
|
|
221
|
+
expect(init.headers).toEqual(expect.objectContaining({
|
|
222
|
+
Authorization: 'Bearer tok',
|
|
223
|
+
'Content-Type': 'application/json',
|
|
224
|
+
}));
|
|
225
|
+
});
|
|
226
|
+
it('dev mode falls back to /dev/reload-app when /dev/reload 4xxs', async () => {
|
|
227
|
+
// First call: /dev/reload 404. Second: /dev/reload-app 200.
|
|
228
|
+
mockFetch
|
|
229
|
+
.mockResolvedValueOnce({ ok: false, status: 404, json: () => Promise.resolve({}) })
|
|
230
|
+
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ok: true }) });
|
|
231
|
+
const client = new P2PClient_1.P2PClient('http://localhost:18080', 'tok');
|
|
232
|
+
const result = await client.reloadApp('dev', { projectName: 'sfmg' });
|
|
233
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
234
|
+
expect(mockFetch.mock.calls[0][0]).toBe('http://localhost:18080/dev/reload');
|
|
235
|
+
expect(mockFetch.mock.calls[1][0]).toBe('http://localhost:18080/dev/reload-app');
|
|
236
|
+
expect(result.ok).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
it('surfaces nativeChangesDetected so the host can prompt for rebuild', async () => {
|
|
239
|
+
mockFetch.mockResolvedValue({
|
|
240
|
+
ok: true,
|
|
241
|
+
json: () => Promise.resolve({
|
|
242
|
+
ok: true,
|
|
243
|
+
nativeChangesDetected: true,
|
|
244
|
+
changeClass: 'native_required',
|
|
245
|
+
}),
|
|
246
|
+
});
|
|
247
|
+
const client = new P2PClient_1.P2PClient('http://localhost:18080', 'tok');
|
|
248
|
+
const result = await client.reloadApp('dev');
|
|
249
|
+
expect(result.nativeChangesDetected).toBe(true);
|
|
250
|
+
expect(result.changeClass).toBe('native_required');
|
|
251
|
+
// Caller-visible message must distinguish native-required from JS-only.
|
|
252
|
+
expect(result.message).toMatch(/native|rebuild/i);
|
|
253
|
+
});
|
|
198
254
|
});
|
|
199
255
|
});
|
|
@@ -76,6 +76,15 @@ describe('YaverFeedback', () => {
|
|
|
76
76
|
expect(cfg.maxRecordingDuration).toBe(60);
|
|
77
77
|
expect(cfg.strictNativeAuth).toBe(true);
|
|
78
78
|
});
|
|
79
|
+
it('promotes quick icon to always when shake is disabled', () => {
|
|
80
|
+
YaverFeedback_1.YaverFeedback.init({
|
|
81
|
+
authToken: 'tok',
|
|
82
|
+
disableShakeGesture: true,
|
|
83
|
+
});
|
|
84
|
+
const cfg = YaverFeedback_1.YaverFeedback.getConfig();
|
|
85
|
+
expect(cfg.disableShakeGesture).toBe(true);
|
|
86
|
+
expect(cfg.quickIcon).toBe('always');
|
|
87
|
+
});
|
|
79
88
|
it('with enabled=false sets enabled to false', () => {
|
|
80
89
|
YaverFeedback_1.YaverFeedback.init({
|
|
81
90
|
authToken: 'tok',
|
|
@@ -17,11 +17,13 @@ describe('React Native SDK types', () => {
|
|
|
17
17
|
authToken: 'tok',
|
|
18
18
|
agentUrl: 'http://192.168.1.10:18080',
|
|
19
19
|
trigger: 'shake',
|
|
20
|
+
disableShakeGesture: true,
|
|
20
21
|
enabled: true,
|
|
21
22
|
maxRecordingDuration: 60,
|
|
22
23
|
strictNativeAuth: true,
|
|
23
24
|
};
|
|
24
25
|
expect(config.trigger).toBe('shake');
|
|
26
|
+
expect(config.disableShakeGesture).toBe(true);
|
|
25
27
|
expect(config.strictNativeAuth).toBe(true);
|
|
26
28
|
});
|
|
27
29
|
it('accepts all trigger types', () => {
|
|
@@ -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 =
|
|
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
|
package/dist/_core/constants.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
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
|
|
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/YaverFeedback.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
// the SSE channel retried with
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
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: '
|
|
35
|
-
hostEmail: '
|
|
34
|
+
hostName: 'Host User',
|
|
35
|
+
hostEmail: 'host@example.com',
|
|
36
36
|
accessScope: 'shared-scoped',
|
|
37
|
-
quicHost: '
|
|
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: '
|
|
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: '
|
|
77
|
-
hostEmail: '
|
|
76
|
+
hostName: 'Host User',
|
|
77
|
+
hostEmail: 'host@example.com',
|
|
78
78
|
accessScope: 'shared-scoped',
|
|
79
|
-
quicHost: '
|
|
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
|
|
package/src/_core/constants.ts
CHANGED
|
@@ -57,9 +57,13 @@ export const DEFAULT_BEACON_UDP_PORT = 19837;
|
|
|
57
57
|
/**
|
|
58
58
|
* How old an agent's last heartbeat can be before the device is
|
|
59
59
|
* considered offline. Mirrors `backend/convex/devices.ts` so
|
|
60
|
-
* Convex + every client agree on the same threshold.
|
|
60
|
+
* Convex + every client agree on the same threshold. The agent
|
|
61
|
+
* heartbeats every 5 min (see `desktop/agent/main.go::heartbeatLoop`),
|
|
62
|
+
* so 6 min tolerates one missed beat + 60 s of jitter without
|
|
63
|
+
* flapping. Sub-minute death detection comes from the P2P bus, not
|
|
64
|
+
* this threshold.
|
|
61
65
|
*/
|
|
62
|
-
export const HEARTBEAT_STALE_MS =
|
|
66
|
+
export const HEARTBEAT_STALE_MS = 360_000;
|
|
63
67
|
|
|
64
68
|
/**
|
|
65
69
|
* How long after the last UDP beacon an agent is still considered
|
package/src/types.ts
CHANGED
|
@@ -123,6 +123,35 @@ export interface FeedbackConfig {
|
|
|
123
123
|
preferredDeviceId?: string;
|
|
124
124
|
/** How feedback collection is triggered */
|
|
125
125
|
trigger?: 'shake' | 'floating-button' | 'manual';
|
|
126
|
+
/**
|
|
127
|
+
* Non-default escape hatch for host apps that want the SDK without
|
|
128
|
+
* shake gesture handling. When enabled:
|
|
129
|
+
* - the SDK does not start ShakeDetector
|
|
130
|
+
* - if `quickIcon` was left as `'auto'`/unset, it is promoted to `'always'`
|
|
131
|
+
* - the app should rely on the draggable quick icon or explicit
|
|
132
|
+
* `YaverFeedback.startReport()` calls instead
|
|
133
|
+
*
|
|
134
|
+
* Intended for builds where another surface owns motion / haptics.
|
|
135
|
+
*/
|
|
136
|
+
disableShakeGesture?: boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Auto-open the BlackBox SSE command channel after init() returns.
|
|
139
|
+
*
|
|
140
|
+
* Default: `true` (0.8.8+). Listens for `reload`, `reload_bundle`,
|
|
141
|
+
* and `status` commands the agent broadcasts after a vibe-coding
|
|
142
|
+
* task commits a fix — drives the auto-reload loop without the host
|
|
143
|
+
* app needing to call `BlackBox.start()` manually.
|
|
144
|
+
*
|
|
145
|
+
* The auto-start is deferred 500ms after init() and gated on having
|
|
146
|
+
* BOTH `agentUrl` and `authToken` resolved, to avoid the iOS 18.3.1
|
|
147
|
+
* rope-string SIGSEGV that the early auto-start in 0.7.6 hit when
|
|
148
|
+
* the agent was in needs-auth mode (tight 401-retry loop in SSE).
|
|
149
|
+
*
|
|
150
|
+
* Set `false` if your host app wants to gate BlackBox start on its
|
|
151
|
+
* own state machine (e.g. only after the user opts into telemetry).
|
|
152
|
+
* Calling `BlackBox.start()` manually is still safe — it's idempotent.
|
|
153
|
+
*/
|
|
154
|
+
autoStartBlackBox?: boolean;
|
|
126
155
|
/**
|
|
127
156
|
* Small tap-to-open icon that floats above the app so the user
|
|
128
157
|
* doesn't have to shake every time they want to open feedback.
|