yaver-feedback-react-native 0.5.3 → 0.5.5
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 +30 -0
- package/dist/AuthOverlay.d.ts +16 -0
- package/dist/AuthOverlay.js +104 -0
- package/dist/BlackBox.d.ts +154 -0
- package/dist/BlackBox.js +395 -0
- package/dist/ConnectionScreen.d.ts +13 -0
- package/dist/ConnectionScreen.js +373 -0
- package/dist/Discovery.d.ts +59 -0
- package/dist/Discovery.js +293 -0
- package/dist/FeedbackModal.d.ts +11 -0
- package/dist/FeedbackModal.js +623 -0
- package/dist/FixReport.d.ts +23 -0
- package/dist/FixReport.js +282 -0
- package/dist/FloatingButton.d.ts +71 -0
- package/dist/FloatingButton.js +778 -0
- package/dist/LoginScreen.d.ts +14 -0
- package/dist/LoginScreen.js +317 -0
- package/dist/MachinePickerScreen.d.ts +19 -0
- package/dist/MachinePickerScreen.js +175 -0
- package/dist/P2PClient.d.ts +136 -0
- package/dist/P2PClient.js +357 -0
- package/dist/ShakeDetector.d.ts +39 -0
- package/dist/ShakeDetector.js +133 -0
- package/dist/YaverFeedback.d.ts +198 -0
- package/dist/YaverFeedback.js +707 -0
- package/dist/YaverUpdates.d.ts +78 -0
- package/dist/YaverUpdates.js +272 -0
- package/dist/__tests__/Discovery.test.d.ts +1 -0
- package/dist/__tests__/Discovery.test.js +164 -0
- package/dist/__tests__/P2PClient.test.d.ts +1 -0
- package/dist/__tests__/P2PClient.test.js +169 -0
- package/dist/__tests__/SDKToken.test.d.ts +1 -0
- package/dist/__tests__/SDKToken.test.js +215 -0
- package/dist/__tests__/YaverFeedback.test.d.ts +1 -0
- package/dist/__tests__/YaverFeedback.test.js +161 -0
- package/dist/__tests__/types.test.d.ts +1 -0
- package/dist/__tests__/types.test.js +219 -0
- package/dist/auth.d.ts +105 -0
- package/dist/auth.js +282 -0
- package/dist/capture.d.ts +27 -0
- package/dist/capture.js +74 -0
- package/dist/expo.d.ts +15 -0
- package/dist/expo.js +62 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +80 -0
- package/dist/types.d.ts +282 -0
- package/dist/types.js +2 -0
- package/dist/upload.d.ts +13 -0
- package/dist/upload.js +59 -0
- package/package.json +6 -3
- package/src/ShakeDetector.ts +22 -1
- package/src/YaverFeedback.ts +29 -0
|
@@ -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
|
+
});
|