yaver-feedback-react-native 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +8 -0
  2. package/dist/AuthOverlay.d.ts +16 -0
  3. package/dist/AuthOverlay.js +104 -0
  4. package/dist/BlackBox.d.ts +154 -0
  5. package/dist/BlackBox.js +395 -0
  6. package/dist/ConnectionScreen.d.ts +13 -0
  7. package/dist/ConnectionScreen.js +373 -0
  8. package/dist/Discovery.d.ts +59 -0
  9. package/dist/Discovery.js +293 -0
  10. package/dist/FeedbackModal.d.ts +11 -0
  11. package/dist/FeedbackModal.js +623 -0
  12. package/dist/FixReport.d.ts +23 -0
  13. package/dist/FixReport.js +282 -0
  14. package/dist/FloatingButton.d.ts +71 -0
  15. package/dist/FloatingButton.js +778 -0
  16. package/dist/LoginScreen.d.ts +14 -0
  17. package/dist/LoginScreen.js +317 -0
  18. package/dist/MachinePickerScreen.d.ts +19 -0
  19. package/dist/MachinePickerScreen.js +175 -0
  20. package/dist/P2PClient.d.ts +136 -0
  21. package/dist/P2PClient.js +357 -0
  22. package/dist/ShakeDetector.d.ts +39 -0
  23. package/dist/ShakeDetector.js +111 -0
  24. package/dist/YaverFeedback.d.ts +198 -0
  25. package/dist/YaverFeedback.js +679 -0
  26. package/dist/YaverUpdates.d.ts +78 -0
  27. package/dist/YaverUpdates.js +272 -0
  28. package/dist/__tests__/Discovery.test.d.ts +1 -0
  29. package/dist/__tests__/Discovery.test.js +164 -0
  30. package/dist/__tests__/P2PClient.test.d.ts +1 -0
  31. package/dist/__tests__/P2PClient.test.js +169 -0
  32. package/dist/__tests__/SDKToken.test.d.ts +1 -0
  33. package/dist/__tests__/SDKToken.test.js +215 -0
  34. package/dist/__tests__/YaverFeedback.test.d.ts +1 -0
  35. package/dist/__tests__/YaverFeedback.test.js +161 -0
  36. package/dist/__tests__/types.test.d.ts +1 -0
  37. package/dist/__tests__/types.test.js +219 -0
  38. package/dist/auth.d.ts +105 -0
  39. package/dist/auth.js +282 -0
  40. package/dist/capture.d.ts +27 -0
  41. package/dist/capture.js +74 -0
  42. package/dist/expo.d.ts +15 -0
  43. package/dist/expo.js +62 -0
  44. package/dist/index.d.ts +48 -0
  45. package/dist/index.js +80 -0
  46. package/dist/types.d.ts +282 -0
  47. package/dist/types.js +2 -0
  48. package/dist/upload.d.ts +13 -0
  49. package/dist/upload.js +59 -0
  50. package/package.json +10 -6
@@ -0,0 +1,78 @@
1
+ export interface YaverUpdatesConfig {
2
+ /** Release channel to track. Default: "production". */
3
+ channel?: string;
4
+ /** Stable user identifier for rollout bucketing. */
5
+ userId?: string;
6
+ /** Poll interval in ms. 0 disables the poll loop. Default: 5 min. */
7
+ interval?: number;
8
+ /** Automatically download new bundles. Default: true. */
9
+ autoDownload?: boolean;
10
+ /**
11
+ * Callback fired when a new bundle has been downloaded and
12
+ * persisted. The dev's app can show an "update available" UI
13
+ * and call `applyPendingUpdate()` to trigger the reload.
14
+ */
15
+ onUpdateReady?: (info: PendingUpdate) => void;
16
+ }
17
+ /** Describes a downloaded-but-not-yet-applied update. */
18
+ export interface PendingUpdate {
19
+ channel: string;
20
+ semver: string;
21
+ md5: string;
22
+ size: number;
23
+ downloadedAt: number;
24
+ bundlePath: string;
25
+ }
26
+ interface LatestResponse {
27
+ ok: boolean;
28
+ channel: string;
29
+ semver?: string;
30
+ size?: number;
31
+ md5?: string;
32
+ hermesBcVersion?: number;
33
+ bundleUrl?: string;
34
+ rolloutPercent: number;
35
+ inRollout: boolean;
36
+ reason?: string;
37
+ }
38
+ export declare class YaverUpdates {
39
+ private static cfg;
40
+ private static pollTimer;
41
+ private static pending;
42
+ private static fs;
43
+ private static started;
44
+ /**
45
+ * Start the OTA poll loop. Safe to call before init() finishes
46
+ * — the call defers until a P2PClient is available.
47
+ */
48
+ static init(config?: YaverUpdatesConfig): void;
49
+ /** Stop the poll loop. */
50
+ static stop(): void;
51
+ /**
52
+ * One-shot poll. Returns the latest release metadata. If a new
53
+ * bundle is available AND autoDownload is true, also downloads
54
+ * and caches it. Resolves to null on network / auth failure.
55
+ */
56
+ static checkForUpdate(): Promise<LatestResponse | null>;
57
+ /**
58
+ * Returns the currently-pending bundle (downloaded but not
59
+ * applied). Null if no update is waiting.
60
+ */
61
+ static getPendingUpdate(): PendingUpdate | null;
62
+ /**
63
+ * Apply a pending update. In dev builds this calls
64
+ * DevSettings.reload(). In release builds without a native
65
+ * module it returns false — the bundle is persisted but the
66
+ * OS will only load it on the next cold start, or after the
67
+ * future YaverUpdates native module lands.
68
+ */
69
+ static applyPendingUpdate(): Promise<boolean>;
70
+ /**
71
+ * Discard the cached pending bundle. Next cold start goes
72
+ * back to whatever was previously loaded.
73
+ */
74
+ static rollback(): Promise<void>;
75
+ private static downloadAndCache;
76
+ private static emitUpdateReady;
77
+ }
78
+ export {};
@@ -0,0 +1,272 @@
1
+ "use strict";
2
+ // YaverUpdates — self-hosted OTA client for React Native apps.
3
+ //
4
+ // End-user apps poll the yaver agent's /releases/latest endpoint
5
+ // through the P2P relay, download the matching Hermes bundle via
6
+ // /releases/bundle, and optionally trigger a JS reload. The
7
+ // bundle is stored on disk for a subsequent cold start to pick
8
+ // up (v1 — this file does NOT hot-swap the live runtime; a
9
+ // follow-up native module Swift/Kotlin will wire into Yaver's
10
+ // existing safeReloadBridge path for true in-process swaps).
11
+ //
12
+ // What v1 ships:
13
+ //
14
+ // - `YaverUpdates.init({ channel, userId, auto })` — polls
15
+ // /releases/latest on boot + every interval, downloads new
16
+ // bundles when inRollout, persists them to a known path,
17
+ // emits a BlackBox `lifecycle` event "update_ready".
18
+ // - `YaverUpdates.checkForUpdate()` — one-shot poll + download.
19
+ // - `YaverUpdates.applyPendingUpdate()` — calls
20
+ // DevSettings.reload() in dev builds, a no-op in release
21
+ // until the native module lands.
22
+ // - `YaverUpdates.rollback()` — deletes the cached bundle so
23
+ // the next cold start ignores it.
24
+ //
25
+ // Storage path is stable across restarts and matches what the
26
+ // future native module will read:
27
+ //
28
+ // <DocumentDirectory>/yaver-updates/<channel>/bundle.hbc
29
+ // <DocumentDirectory>/yaver-updates/<channel>/metadata.json
30
+ //
31
+ // The SDK writes to a temp path first and renames on success so
32
+ // a mid-download crash never leaves a half-written bundle in
33
+ // place.
34
+ //
35
+ // SELF-HOSTING WIN: the dev's own agent serves the bundle
36
+ // through the dev's own relay. No EAS Update subscription, no
37
+ // CodePush dependency, no central vendor. The bundle never
38
+ // touches any server the dev doesn't control.
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.YaverUpdates = void 0;
41
+ const react_native_1 = require("react-native");
42
+ const BlackBox_1 = require("./BlackBox");
43
+ const YaverFeedback_1 = require("./YaverFeedback");
44
+ // We feature-detect react-native-fs instead of hard-requiring
45
+ // it. Devs who don't have it installed still get
46
+ // checkForUpdate() polling + the BlackBox event, just without
47
+ // disk persistence.
48
+ function loadFS() {
49
+ try {
50
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
51
+ const fs = require('react-native-fs');
52
+ return fs;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ class YaverUpdates {
59
+ /**
60
+ * Start the OTA poll loop. Safe to call before init() finishes
61
+ * — the call defers until a P2PClient is available.
62
+ */
63
+ static init(config) {
64
+ if (YaverUpdates.started) {
65
+ YaverUpdates.cfg = {
66
+ channel: config?.channel ?? YaverUpdates.cfg?.channel ?? 'production',
67
+ userId: config?.userId ?? YaverUpdates.cfg?.userId ?? 'anonymous',
68
+ interval: config?.interval ?? YaverUpdates.cfg?.interval ?? 5 * 60 * 1000,
69
+ autoDownload: config?.autoDownload ?? YaverUpdates.cfg?.autoDownload ?? true,
70
+ onUpdateReady: config?.onUpdateReady ?? YaverUpdates.cfg?.onUpdateReady ?? (() => { }),
71
+ };
72
+ return;
73
+ }
74
+ YaverUpdates.started = true;
75
+ YaverUpdates.fs = loadFS();
76
+ YaverUpdates.cfg = {
77
+ channel: config?.channel ?? 'production',
78
+ userId: config?.userId ?? 'anonymous',
79
+ interval: config?.interval ?? 5 * 60 * 1000,
80
+ autoDownload: config?.autoDownload ?? true,
81
+ onUpdateReady: config?.onUpdateReady ?? (() => { }),
82
+ };
83
+ // Kick off the first check immediately so devs see an
84
+ // update-available banner on the very next app cold start.
85
+ YaverUpdates.checkForUpdate().catch(() => { });
86
+ if (YaverUpdates.cfg.interval > 0) {
87
+ YaverUpdates.pollTimer = setInterval(() => YaverUpdates.checkForUpdate().catch(() => { }), YaverUpdates.cfg.interval);
88
+ }
89
+ }
90
+ /** Stop the poll loop. */
91
+ static stop() {
92
+ if (YaverUpdates.pollTimer) {
93
+ clearInterval(YaverUpdates.pollTimer);
94
+ YaverUpdates.pollTimer = null;
95
+ }
96
+ }
97
+ /**
98
+ * One-shot poll. Returns the latest release metadata. If a new
99
+ * bundle is available AND autoDownload is true, also downloads
100
+ * and caches it. Resolves to null on network / auth failure.
101
+ */
102
+ static async checkForUpdate() {
103
+ const cfg = YaverUpdates.cfg;
104
+ if (!cfg)
105
+ return null;
106
+ const client = YaverFeedback_1.YaverFeedback.getP2PClient();
107
+ if (!client)
108
+ return null;
109
+ const latest = await client.releasesLatest(cfg.channel, cfg.userId);
110
+ if (!latest || !latest.semver)
111
+ return latest;
112
+ if (!latest.inRollout)
113
+ return latest;
114
+ // Skip if we already have this bundle cached.
115
+ if (YaverUpdates.pending && YaverUpdates.pending.semver === latest.semver) {
116
+ return latest;
117
+ }
118
+ if (cfg.autoDownload) {
119
+ await YaverUpdates.downloadAndCache(client, latest);
120
+ }
121
+ return latest;
122
+ }
123
+ /**
124
+ * Returns the currently-pending bundle (downloaded but not
125
+ * applied). Null if no update is waiting.
126
+ */
127
+ static getPendingUpdate() {
128
+ return YaverUpdates.pending;
129
+ }
130
+ /**
131
+ * Apply a pending update. In dev builds this calls
132
+ * DevSettings.reload(). In release builds without a native
133
+ * module it returns false — the bundle is persisted but the
134
+ * OS will only load it on the next cold start, or after the
135
+ * future YaverUpdates native module lands.
136
+ */
137
+ static async applyPendingUpdate() {
138
+ if (!YaverUpdates.pending)
139
+ return false;
140
+ try {
141
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
142
+ const rn = require('react-native');
143
+ if (rn?.DevSettings?.reload) {
144
+ rn.DevSettings.reload();
145
+ return true;
146
+ }
147
+ }
148
+ catch {
149
+ // fall through
150
+ }
151
+ return false;
152
+ }
153
+ /**
154
+ * Discard the cached pending bundle. Next cold start goes
155
+ * back to whatever was previously loaded.
156
+ */
157
+ static async rollback() {
158
+ if (!YaverUpdates.pending || !YaverUpdates.fs) {
159
+ YaverUpdates.pending = null;
160
+ return;
161
+ }
162
+ try {
163
+ await YaverUpdates.fs.unlink?.(YaverUpdates.pending.bundlePath);
164
+ }
165
+ catch {
166
+ // swallow — the bundle may already be gone
167
+ }
168
+ YaverUpdates.pending = null;
169
+ }
170
+ // --- internals ---------------------------------------------------
171
+ static async downloadAndCache(client, latest) {
172
+ if (!latest.semver || !latest.md5 || !latest.size)
173
+ return;
174
+ const bytes = await client.releasesDownload(latest.channel, latest.semver);
175
+ if (!bytes)
176
+ return;
177
+ const info = {
178
+ channel: latest.channel,
179
+ semver: latest.semver,
180
+ md5: latest.md5,
181
+ size: latest.size,
182
+ downloadedAt: Date.now(),
183
+ bundlePath: '',
184
+ };
185
+ const fs = YaverUpdates.fs;
186
+ if (!fs || !fs.DocumentDirectoryPath || !fs.writeFile) {
187
+ // No FS — still expose the pending record in-memory so
188
+ // the dev's app can react to the BlackBox event.
189
+ YaverUpdates.pending = info;
190
+ YaverUpdates.emitUpdateReady(info);
191
+ return;
192
+ }
193
+ const dir = `${fs.DocumentDirectoryPath}/yaver-updates/${latest.channel}`;
194
+ try {
195
+ await fs.mkdir?.(dir);
196
+ }
197
+ catch {
198
+ // mkdir -p — directory may already exist
199
+ }
200
+ const bundlePath = `${dir}/bundle.hbc`;
201
+ const tmpPath = `${bundlePath}.tmp`;
202
+ // react-native-fs writeFile accepts base64 or utf8; Hermes
203
+ // bundles are binary so we encode the ArrayBuffer as base64.
204
+ const base64 = bufferToBase64(bytes);
205
+ await fs.writeFile(tmpPath, base64, 'base64');
206
+ try {
207
+ await fs.unlink?.(bundlePath);
208
+ }
209
+ catch {
210
+ // fine — no previous bundle
211
+ }
212
+ // We can't atomic-rename without a native bridge, so the
213
+ // tmpPath -> bundlePath swap is a copy + delete. For a dev
214
+ // runtime this is fine; the native shim will tighten it.
215
+ try {
216
+ const rename = fs.moveFile;
217
+ if (rename) {
218
+ await rename(tmpPath, bundlePath);
219
+ }
220
+ else {
221
+ // Best-effort fallback: write directly to bundlePath on
222
+ // the next attempt. Leaves a .tmp around, which the next
223
+ // run will overwrite.
224
+ }
225
+ }
226
+ catch {
227
+ // swallow
228
+ }
229
+ info.bundlePath = bundlePath;
230
+ YaverUpdates.pending = info;
231
+ YaverUpdates.emitUpdateReady(info);
232
+ }
233
+ static emitUpdateReady(info) {
234
+ BlackBox_1.BlackBox.lifecycle('yaver-updates: bundle downloaded', {
235
+ channel: info.channel,
236
+ semver: info.semver,
237
+ md5: info.md5,
238
+ size: info.size,
239
+ platform: react_native_1.Platform.OS,
240
+ });
241
+ const cb = YaverUpdates.cfg?.onUpdateReady;
242
+ if (cb) {
243
+ try {
244
+ cb(info);
245
+ }
246
+ catch {
247
+ // dev callback threw — don't let it stall the poll loop
248
+ }
249
+ }
250
+ }
251
+ }
252
+ exports.YaverUpdates = YaverUpdates;
253
+ YaverUpdates.cfg = null;
254
+ YaverUpdates.pollTimer = null;
255
+ YaverUpdates.pending = null;
256
+ YaverUpdates.fs = null;
257
+ YaverUpdates.started = false;
258
+ // Small ArrayBuffer -> base64 helper with no external deps so
259
+ // the SDK package doesn't balloon. Safe for small bundles
260
+ // (~10MB worst case) and runs on every RN target without
261
+ // polyfills.
262
+ function bufferToBase64(buf) {
263
+ const bytes = new Uint8Array(buf);
264
+ let binary = '';
265
+ const chunk = 0x8000;
266
+ for (let i = 0; i < bytes.length; i += chunk) {
267
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
268
+ }
269
+ // btoa exists in both React Native's Hermes and Node for tests.
270
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
271
+ return globalThis.btoa(binary);
272
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const Discovery_1 = require("../Discovery");
4
+ // Mock fetch globally
5
+ const mockFetch = jest.fn();
6
+ global.fetch = mockFetch;
7
+ // Mock AsyncStorage. Discovery.ts requires it via `.default`, so the mock
8
+ // must expose its API on a `default` property too.
9
+ const mockStorage = {};
10
+ const mockAsyncStorage = {
11
+ getItem: jest.fn((key) => Promise.resolve(mockStorage[key] || null)),
12
+ setItem: jest.fn((key, value) => {
13
+ mockStorage[key] = value;
14
+ return Promise.resolve();
15
+ }),
16
+ removeItem: jest.fn((key) => {
17
+ delete mockStorage[key];
18
+ return Promise.resolve();
19
+ }),
20
+ };
21
+ jest.mock('@react-native-async-storage/async-storage', () => ({
22
+ __esModule: true,
23
+ default: mockAsyncStorage,
24
+ ...mockAsyncStorage,
25
+ }));
26
+ // Mock AbortController
27
+ class MockAbortController {
28
+ constructor() {
29
+ this.signal = {};
30
+ this.abort = jest.fn();
31
+ }
32
+ }
33
+ global.AbortController = MockAbortController;
34
+ beforeEach(() => {
35
+ jest.clearAllMocks();
36
+ mockFetch.mockReset();
37
+ // Clear mock storage
38
+ Object.keys(mockStorage).forEach((key) => delete mockStorage[key]);
39
+ });
40
+ describe('YaverDiscovery', () => {
41
+ describe('probe()', () => {
42
+ it('returns null for unreachable URLs', async () => {
43
+ mockFetch.mockRejectedValue(new Error('Network error'));
44
+ const result = await Discovery_1.YaverDiscovery.probe('http://192.168.1.99:18080');
45
+ expect(result).toBeNull();
46
+ });
47
+ it('returns null for non-ok responses', async () => {
48
+ mockFetch.mockResolvedValue({
49
+ ok: false,
50
+ status: 500,
51
+ });
52
+ const result = await Discovery_1.YaverDiscovery.probe('http://192.168.1.1:18080');
53
+ expect(result).toBeNull();
54
+ });
55
+ it('returns DiscoveryResult for a reachable agent', async () => {
56
+ mockFetch.mockResolvedValue({
57
+ ok: true,
58
+ json: () => Promise.resolve({ hostname: 'MacBook-Air', version: '1.44.0' }),
59
+ });
60
+ const result = await Discovery_1.YaverDiscovery.probe('http://192.168.1.10:18080');
61
+ expect(result).not.toBeNull();
62
+ expect(result.url).toBe('http://192.168.1.10:18080');
63
+ expect(result.hostname).toBe('MacBook-Air');
64
+ expect(result.version).toBe('1.44.0');
65
+ expect(typeof result.latency).toBe('number');
66
+ });
67
+ it('strips trailing slash from URL', async () => {
68
+ mockFetch.mockResolvedValue({
69
+ ok: true,
70
+ json: () => Promise.resolve({ hostname: 'test', version: '1.0' }),
71
+ });
72
+ const result = await Discovery_1.YaverDiscovery.probe('http://192.168.1.10:18080/');
73
+ expect(result.url).toBe('http://192.168.1.10:18080');
74
+ });
75
+ it('handles health endpoint returning non-JSON gracefully', async () => {
76
+ mockFetch.mockResolvedValue({
77
+ ok: true,
78
+ json: () => Promise.reject(new Error('not JSON')),
79
+ });
80
+ const result = await Discovery_1.YaverDiscovery.probe('http://192.168.1.10:18080');
81
+ expect(result).not.toBeNull();
82
+ expect(result.hostname).toBe('Unknown');
83
+ expect(result.version).toBe('unknown');
84
+ });
85
+ });
86
+ describe('getStored()', () => {
87
+ it('returns null when no stored connection', async () => {
88
+ const result = await Discovery_1.YaverDiscovery.getStored();
89
+ expect(result).toBeNull();
90
+ });
91
+ it('returns stored connection when available', async () => {
92
+ const AsyncStorage = require('@react-native-async-storage/async-storage');
93
+ mockStorage['yaver_feedback_agent'] = JSON.stringify({
94
+ url: 'http://192.168.1.10:18080',
95
+ hostname: 'MacBook',
96
+ });
97
+ const result = await Discovery_1.YaverDiscovery.getStored();
98
+ expect(result).not.toBeNull();
99
+ expect(result.url).toBe('http://192.168.1.10:18080');
100
+ expect(result.hostname).toBe('MacBook');
101
+ });
102
+ it('returns null for invalid stored JSON', async () => {
103
+ mockStorage['yaver_feedback_agent'] = 'not-json';
104
+ const result = await Discovery_1.YaverDiscovery.getStored();
105
+ expect(result).toBeNull();
106
+ });
107
+ it('returns null for stored data without url field', async () => {
108
+ mockStorage['yaver_feedback_agent'] = JSON.stringify({ hostname: 'test' });
109
+ const result = await Discovery_1.YaverDiscovery.getStored();
110
+ expect(result).toBeNull();
111
+ });
112
+ });
113
+ describe('store() and getStored() round-trip', () => {
114
+ it('stores and retrieves a DiscoveryResult', async () => {
115
+ const discovery = {
116
+ url: 'http://10.0.0.50:18080',
117
+ hostname: 'DevMachine',
118
+ version: '1.44.0',
119
+ latency: 5,
120
+ };
121
+ await Discovery_1.YaverDiscovery.store(discovery);
122
+ const stored = await Discovery_1.YaverDiscovery.getStored();
123
+ expect(stored).not.toBeNull();
124
+ expect(stored.url).toBe('http://10.0.0.50:18080');
125
+ expect(stored.hostname).toBe('DevMachine');
126
+ });
127
+ });
128
+ describe('clear()', () => {
129
+ it('removes stored connection', async () => {
130
+ const discovery = {
131
+ url: 'http://10.0.0.1:18080',
132
+ hostname: 'Test',
133
+ version: '1.0',
134
+ latency: 3,
135
+ };
136
+ await Discovery_1.YaverDiscovery.store(discovery);
137
+ const beforeClear = await Discovery_1.YaverDiscovery.getStored();
138
+ expect(beforeClear).not.toBeNull();
139
+ await Discovery_1.YaverDiscovery.clear();
140
+ const afterClear = await Discovery_1.YaverDiscovery.getStored();
141
+ expect(afterClear).toBeNull();
142
+ });
143
+ });
144
+ describe('connect()', () => {
145
+ it('probes and stores on success', async () => {
146
+ mockFetch.mockResolvedValue({
147
+ ok: true,
148
+ json: () => Promise.resolve({ hostname: 'Agent', version: '2.0' }),
149
+ });
150
+ const result = await Discovery_1.YaverDiscovery.connect('http://192.168.1.5:18080');
151
+ expect(result).not.toBeNull();
152
+ expect(result.hostname).toBe('Agent');
153
+ // Should be stored
154
+ const stored = await Discovery_1.YaverDiscovery.getStored();
155
+ expect(stored).not.toBeNull();
156
+ expect(stored.url).toBe('http://192.168.1.5:18080');
157
+ });
158
+ it('returns null and does not store on failure', async () => {
159
+ mockFetch.mockRejectedValue(new Error('unreachable'));
160
+ const result = await Discovery_1.YaverDiscovery.connect('http://192.168.1.99:18080');
161
+ expect(result).toBeNull();
162
+ });
163
+ });
164
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,169 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const P2PClient_1 = require("../P2PClient");
4
+ // Mock react-native Platform
5
+ jest.mock('react-native', () => ({
6
+ Platform: { OS: 'ios' },
7
+ }));
8
+ // Mock fetch globally
9
+ const mockFetch = jest.fn();
10
+ global.fetch = mockFetch;
11
+ // Mock AbortController
12
+ class MockAbortController {
13
+ constructor() {
14
+ this.signal = {};
15
+ this.abort = jest.fn();
16
+ }
17
+ }
18
+ global.AbortController = MockAbortController;
19
+ beforeEach(() => {
20
+ jest.clearAllMocks();
21
+ mockFetch.mockReset();
22
+ });
23
+ describe('P2PClient', () => {
24
+ describe('constructor', () => {
25
+ it('sets baseUrl and authToken', () => {
26
+ const client = new P2PClient_1.P2PClient('http://192.168.1.10:18080', 'my-token');
27
+ // Verify via getArtifactUrl which uses baseUrl
28
+ expect(client.getArtifactUrl('build-123')).toBe('http://192.168.1.10:18080/builds/build-123/artifact');
29
+ });
30
+ it('strips trailing slash from baseUrl', () => {
31
+ const client = new P2PClient_1.P2PClient('http://192.168.1.10:18080/', 'tok');
32
+ expect(client.getArtifactUrl('b1')).toBe('http://192.168.1.10:18080/builds/b1/artifact');
33
+ });
34
+ });
35
+ describe('health()', () => {
36
+ it('returns false for unreachable agent', async () => {
37
+ mockFetch.mockRejectedValue(new Error('Network error'));
38
+ const client = new P2PClient_1.P2PClient('http://192.168.1.99:18080', 'tok');
39
+ const result = await client.health();
40
+ expect(result).toBe(false);
41
+ });
42
+ it('returns true when agent is reachable', async () => {
43
+ mockFetch.mockResolvedValue({ ok: true });
44
+ const client = new P2PClient_1.P2PClient('http://192.168.1.10:18080', 'tok');
45
+ const result = await client.health();
46
+ expect(result).toBe(true);
47
+ });
48
+ it('returns false for non-ok response', async () => {
49
+ mockFetch.mockResolvedValue({ ok: false, status: 503 });
50
+ const client = new P2PClient_1.P2PClient('http://192.168.1.10:18080', 'tok');
51
+ const result = await client.health();
52
+ expect(result).toBe(false);
53
+ });
54
+ it('calls /health endpoint', async () => {
55
+ mockFetch.mockResolvedValue({ ok: true });
56
+ const client = new P2PClient_1.P2PClient('http://10.0.0.1:18080', 'tok');
57
+ await client.health();
58
+ expect(mockFetch).toHaveBeenCalledWith('http://10.0.0.1:18080/health', expect.objectContaining({ method: 'GET' }));
59
+ });
60
+ });
61
+ describe('getArtifactUrl()', () => {
62
+ it('constructs correct URL for a build ID', () => {
63
+ const client = new P2PClient_1.P2PClient('http://192.168.1.10:18080', 'tok');
64
+ expect(client.getArtifactUrl('abc-123')).toBe('http://192.168.1.10:18080/builds/abc-123/artifact');
65
+ });
66
+ it('works with different base URLs', () => {
67
+ const client = new P2PClient_1.P2PClient('http://10.0.0.50:9090', 'tok');
68
+ expect(client.getArtifactUrl('xyz')).toBe('http://10.0.0.50:9090/builds/xyz/artifact');
69
+ });
70
+ });
71
+ describe('setBaseUrl()', () => {
72
+ it('updates the base URL used by subsequent calls', () => {
73
+ const client = new P2PClient_1.P2PClient('http://old-host:18080', 'tok');
74
+ client.setBaseUrl('http://new-host:18080');
75
+ expect(client.getArtifactUrl('b1')).toBe('http://new-host:18080/builds/b1/artifact');
76
+ });
77
+ it('strips trailing slash from new URL', () => {
78
+ const client = new P2PClient_1.P2PClient('http://old:18080', 'tok');
79
+ client.setBaseUrl('http://new:18080/');
80
+ expect(client.getArtifactUrl('b1')).toBe('http://new:18080/builds/b1/artifact');
81
+ });
82
+ });
83
+ describe('info()', () => {
84
+ it('returns agent info on success', async () => {
85
+ mockFetch.mockResolvedValue({
86
+ ok: true,
87
+ json: () => Promise.resolve({ hostname: 'MacBook', version: '1.44.0', platform: 'darwin' }),
88
+ text: () => Promise.resolve(''),
89
+ });
90
+ const client = new P2PClient_1.P2PClient('http://localhost:18080', 'tok');
91
+ const info = await client.info();
92
+ expect(info.hostname).toBe('MacBook');
93
+ expect(info.version).toBe('1.44.0');
94
+ expect(info.platform).toBe('darwin');
95
+ });
96
+ it('sends Authorization header', async () => {
97
+ mockFetch.mockResolvedValue({
98
+ ok: true,
99
+ json: () => Promise.resolve({ hostname: 'test' }),
100
+ text: () => Promise.resolve(''),
101
+ });
102
+ const client = new P2PClient_1.P2PClient('http://localhost:18080', 'my-secret-token');
103
+ await client.info();
104
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:18080/health', expect.objectContaining({
105
+ headers: expect.objectContaining({
106
+ Authorization: 'Bearer my-secret-token',
107
+ }),
108
+ }));
109
+ });
110
+ });
111
+ describe('uploadFeedback()', () => {
112
+ it('sends a POST request to /feedback', async () => {
113
+ mockFetch.mockResolvedValue({
114
+ ok: true,
115
+ json: () => Promise.resolve({ id: 'report-456' }),
116
+ text: () => Promise.resolve(''),
117
+ });
118
+ const client = new P2PClient_1.P2PClient('http://localhost:18080', 'tok');
119
+ const bundle = {
120
+ metadata: {
121
+ timestamp: '2026-03-24T00:00:00Z',
122
+ device: {
123
+ platform: 'ios',
124
+ osVersion: '18.0',
125
+ model: 'iPhone 16',
126
+ screenWidth: 393,
127
+ screenHeight: 852,
128
+ },
129
+ app: { bundleId: 'com.test', version: '1.0' },
130
+ userNote: 'Button is broken',
131
+ },
132
+ screenshots: [],
133
+ };
134
+ const reportId = await client.uploadFeedback(bundle);
135
+ expect(reportId).toBe('report-456');
136
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:18080/feedback', expect.objectContaining({ method: 'POST' }));
137
+ });
138
+ it('throws on non-ok response', async () => {
139
+ mockFetch.mockResolvedValue({
140
+ ok: false,
141
+ status: 500,
142
+ text: () => Promise.resolve('Internal Server Error'),
143
+ });
144
+ const client = new P2PClient_1.P2PClient('http://localhost:18080', 'tok');
145
+ const bundle = {
146
+ metadata: {
147
+ timestamp: '2026-03-24T00:00:00Z',
148
+ device: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
149
+ app: {},
150
+ },
151
+ screenshots: [],
152
+ };
153
+ await expect(client.uploadFeedback(bundle)).rejects.toThrow('Upload failed');
154
+ });
155
+ });
156
+ describe('listBuilds()', () => {
157
+ it('returns builds array on success', async () => {
158
+ const builds = [{ id: '1', platform: 'ios', status: 'complete' }];
159
+ mockFetch.mockResolvedValue({
160
+ ok: true,
161
+ json: () => Promise.resolve({ builds }),
162
+ text: () => Promise.resolve(''),
163
+ });
164
+ const client = new P2PClient_1.P2PClient('http://localhost:18080', 'tok');
165
+ const result = await client.listBuilds();
166
+ expect(result).toEqual(builds);
167
+ });
168
+ });
169
+ });