yaver-feedback-react-native 0.2.0
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 +690 -0
- package/app.plugin.js +70 -0
- package/package.json +39 -0
- package/src/BlackBox.ts +317 -0
- package/src/ConnectionScreen.tsx +400 -0
- package/src/Discovery.ts +223 -0
- package/src/FeedbackModal.tsx +678 -0
- package/src/FixReport.tsx +313 -0
- package/src/FloatingButton.tsx +860 -0
- package/src/P2PClient.ts +303 -0
- package/src/ShakeDetector.ts +57 -0
- package/src/YaverFeedback.ts +345 -0
- package/src/__tests__/Discovery.test.ts +187 -0
- package/src/__tests__/P2PClient.test.ts +218 -0
- package/src/__tests__/SDKToken.test.ts +268 -0
- package/src/__tests__/YaverFeedback.test.ts +189 -0
- package/src/__tests__/types.test.ts +247 -0
- package/src/capture.ts +84 -0
- package/src/expo.ts +62 -0
- package/src/index.ts +54 -0
- package/src/types.ts +251 -0
- package/src/upload.ts +74 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { YaverDiscovery, DiscoveryResult } from '../Discovery';
|
|
2
|
+
|
|
3
|
+
// Mock fetch globally
|
|
4
|
+
const mockFetch = jest.fn();
|
|
5
|
+
global.fetch = mockFetch as any;
|
|
6
|
+
|
|
7
|
+
// Mock AsyncStorage
|
|
8
|
+
const mockStorage: Record<string, string> = {};
|
|
9
|
+
jest.mock('@react-native-async-storage/async-storage', () => ({
|
|
10
|
+
getItem: jest.fn((key: string) => Promise.resolve(mockStorage[key] || null)),
|
|
11
|
+
setItem: jest.fn((key: string, value: string) => {
|
|
12
|
+
mockStorage[key] = value;
|
|
13
|
+
return Promise.resolve();
|
|
14
|
+
}),
|
|
15
|
+
removeItem: jest.fn((key: string) => {
|
|
16
|
+
delete mockStorage[key];
|
|
17
|
+
return Promise.resolve();
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// Mock AbortController
|
|
22
|
+
class MockAbortController {
|
|
23
|
+
signal = {};
|
|
24
|
+
abort = jest.fn();
|
|
25
|
+
}
|
|
26
|
+
global.AbortController = MockAbortController as any;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
jest.clearAllMocks();
|
|
30
|
+
mockFetch.mockReset();
|
|
31
|
+
// Clear mock storage
|
|
32
|
+
Object.keys(mockStorage).forEach((key) => delete mockStorage[key]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('YaverDiscovery', () => {
|
|
36
|
+
describe('probe()', () => {
|
|
37
|
+
it('returns null for unreachable URLs', async () => {
|
|
38
|
+
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
39
|
+
|
|
40
|
+
const result = await YaverDiscovery.probe('http://192.168.1.99:18080');
|
|
41
|
+
expect(result).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns null for non-ok responses', async () => {
|
|
45
|
+
mockFetch.mockResolvedValue({
|
|
46
|
+
ok: false,
|
|
47
|
+
status: 500,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const result = await YaverDiscovery.probe('http://192.168.1.1:18080');
|
|
51
|
+
expect(result).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns DiscoveryResult for a reachable agent', async () => {
|
|
55
|
+
mockFetch.mockResolvedValue({
|
|
56
|
+
ok: true,
|
|
57
|
+
json: () => Promise.resolve({ hostname: 'MacBook-Air', version: '1.44.0' }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const result = await 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
|
+
|
|
68
|
+
it('strips trailing slash from URL', async () => {
|
|
69
|
+
mockFetch.mockResolvedValue({
|
|
70
|
+
ok: true,
|
|
71
|
+
json: () => Promise.resolve({ hostname: 'test', version: '1.0' }),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const result = await YaverDiscovery.probe('http://192.168.1.10:18080/');
|
|
75
|
+
expect(result!.url).toBe('http://192.168.1.10:18080');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('handles health endpoint returning non-JSON gracefully', async () => {
|
|
79
|
+
mockFetch.mockResolvedValue({
|
|
80
|
+
ok: true,
|
|
81
|
+
json: () => Promise.reject(new Error('not JSON')),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const result = await YaverDiscovery.probe('http://192.168.1.10:18080');
|
|
85
|
+
expect(result).not.toBeNull();
|
|
86
|
+
expect(result!.hostname).toBe('Unknown');
|
|
87
|
+
expect(result!.version).toBe('unknown');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('getStored()', () => {
|
|
92
|
+
it('returns null when no stored connection', async () => {
|
|
93
|
+
const result = await YaverDiscovery.getStored();
|
|
94
|
+
expect(result).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('returns stored connection when available', async () => {
|
|
98
|
+
const AsyncStorage = require('@react-native-async-storage/async-storage');
|
|
99
|
+
mockStorage['yaver_feedback_agent'] = JSON.stringify({
|
|
100
|
+
url: 'http://192.168.1.10:18080',
|
|
101
|
+
hostname: 'MacBook',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const result = await YaverDiscovery.getStored();
|
|
105
|
+
expect(result).not.toBeNull();
|
|
106
|
+
expect(result!.url).toBe('http://192.168.1.10:18080');
|
|
107
|
+
expect(result!.hostname).toBe('MacBook');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns null for invalid stored JSON', async () => {
|
|
111
|
+
mockStorage['yaver_feedback_agent'] = 'not-json';
|
|
112
|
+
|
|
113
|
+
const result = await YaverDiscovery.getStored();
|
|
114
|
+
expect(result).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns null for stored data without url field', async () => {
|
|
118
|
+
mockStorage['yaver_feedback_agent'] = JSON.stringify({ hostname: 'test' });
|
|
119
|
+
|
|
120
|
+
const result = await YaverDiscovery.getStored();
|
|
121
|
+
expect(result).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('store() and getStored() round-trip', () => {
|
|
126
|
+
it('stores and retrieves a DiscoveryResult', async () => {
|
|
127
|
+
const discovery: DiscoveryResult = {
|
|
128
|
+
url: 'http://10.0.0.50:18080',
|
|
129
|
+
hostname: 'DevMachine',
|
|
130
|
+
version: '1.44.0',
|
|
131
|
+
latency: 5,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
await YaverDiscovery.store(discovery);
|
|
135
|
+
|
|
136
|
+
const stored = await YaverDiscovery.getStored();
|
|
137
|
+
expect(stored).not.toBeNull();
|
|
138
|
+
expect(stored!.url).toBe('http://10.0.0.50:18080');
|
|
139
|
+
expect(stored!.hostname).toBe('DevMachine');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('clear()', () => {
|
|
144
|
+
it('removes stored connection', async () => {
|
|
145
|
+
const discovery: DiscoveryResult = {
|
|
146
|
+
url: 'http://10.0.0.1:18080',
|
|
147
|
+
hostname: 'Test',
|
|
148
|
+
version: '1.0',
|
|
149
|
+
latency: 3,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
await YaverDiscovery.store(discovery);
|
|
153
|
+
const beforeClear = await YaverDiscovery.getStored();
|
|
154
|
+
expect(beforeClear).not.toBeNull();
|
|
155
|
+
|
|
156
|
+
await YaverDiscovery.clear();
|
|
157
|
+
|
|
158
|
+
const afterClear = await YaverDiscovery.getStored();
|
|
159
|
+
expect(afterClear).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('connect()', () => {
|
|
164
|
+
it('probes and stores on success', async () => {
|
|
165
|
+
mockFetch.mockResolvedValue({
|
|
166
|
+
ok: true,
|
|
167
|
+
json: () => Promise.resolve({ hostname: 'Agent', version: '2.0' }),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const result = await YaverDiscovery.connect('http://192.168.1.5:18080');
|
|
171
|
+
expect(result).not.toBeNull();
|
|
172
|
+
expect(result!.hostname).toBe('Agent');
|
|
173
|
+
|
|
174
|
+
// Should be stored
|
|
175
|
+
const stored = await YaverDiscovery.getStored();
|
|
176
|
+
expect(stored).not.toBeNull();
|
|
177
|
+
expect(stored!.url).toBe('http://192.168.1.5:18080');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('returns null and does not store on failure', async () => {
|
|
181
|
+
mockFetch.mockRejectedValue(new Error('unreachable'));
|
|
182
|
+
|
|
183
|
+
const result = await YaverDiscovery.connect('http://192.168.1.99:18080');
|
|
184
|
+
expect(result).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { P2PClient } from '../P2PClient';
|
|
2
|
+
|
|
3
|
+
// Mock react-native Platform
|
|
4
|
+
jest.mock('react-native', () => ({
|
|
5
|
+
Platform: { OS: 'ios' },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
// Mock fetch globally
|
|
9
|
+
const mockFetch = jest.fn();
|
|
10
|
+
global.fetch = mockFetch as any;
|
|
11
|
+
|
|
12
|
+
// Mock AbortController
|
|
13
|
+
class MockAbortController {
|
|
14
|
+
signal = {};
|
|
15
|
+
abort = jest.fn();
|
|
16
|
+
}
|
|
17
|
+
global.AbortController = MockAbortController as any;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
jest.clearAllMocks();
|
|
21
|
+
mockFetch.mockReset();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('P2PClient', () => {
|
|
25
|
+
describe('constructor', () => {
|
|
26
|
+
it('sets baseUrl and authToken', () => {
|
|
27
|
+
const client = new P2PClient('http://192.168.1.10:18080', 'my-token');
|
|
28
|
+
// Verify via getArtifactUrl which uses baseUrl
|
|
29
|
+
expect(client.getArtifactUrl('build-123')).toBe(
|
|
30
|
+
'http://192.168.1.10:18080/builds/build-123/artifact'
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('strips trailing slash from baseUrl', () => {
|
|
35
|
+
const client = new P2PClient('http://192.168.1.10:18080/', 'tok');
|
|
36
|
+
expect(client.getArtifactUrl('b1')).toBe(
|
|
37
|
+
'http://192.168.1.10:18080/builds/b1/artifact'
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('health()', () => {
|
|
43
|
+
it('returns false for unreachable agent', async () => {
|
|
44
|
+
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
45
|
+
|
|
46
|
+
const client = new P2PClient('http://192.168.1.99:18080', 'tok');
|
|
47
|
+
const result = await client.health();
|
|
48
|
+
expect(result).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns true when agent is reachable', async () => {
|
|
52
|
+
mockFetch.mockResolvedValue({ ok: true });
|
|
53
|
+
|
|
54
|
+
const client = new P2PClient('http://192.168.1.10:18080', 'tok');
|
|
55
|
+
const result = await client.health();
|
|
56
|
+
expect(result).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns false for non-ok response', async () => {
|
|
60
|
+
mockFetch.mockResolvedValue({ ok: false, status: 503 });
|
|
61
|
+
|
|
62
|
+
const client = new P2PClient('http://192.168.1.10:18080', 'tok');
|
|
63
|
+
const result = await client.health();
|
|
64
|
+
expect(result).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('calls /health endpoint', async () => {
|
|
68
|
+
mockFetch.mockResolvedValue({ ok: true });
|
|
69
|
+
|
|
70
|
+
const client = new P2PClient('http://10.0.0.1:18080', 'tok');
|
|
71
|
+
await client.health();
|
|
72
|
+
|
|
73
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
74
|
+
'http://10.0.0.1:18080/health',
|
|
75
|
+
expect.objectContaining({ method: 'GET' })
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('getArtifactUrl()', () => {
|
|
81
|
+
it('constructs correct URL for a build ID', () => {
|
|
82
|
+
const client = new P2PClient('http://192.168.1.10:18080', 'tok');
|
|
83
|
+
expect(client.getArtifactUrl('abc-123')).toBe(
|
|
84
|
+
'http://192.168.1.10:18080/builds/abc-123/artifact'
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('works with different base URLs', () => {
|
|
89
|
+
const client = new P2PClient('http://10.0.0.50:9090', 'tok');
|
|
90
|
+
expect(client.getArtifactUrl('xyz')).toBe(
|
|
91
|
+
'http://10.0.0.50:9090/builds/xyz/artifact'
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('setBaseUrl()', () => {
|
|
97
|
+
it('updates the base URL used by subsequent calls', () => {
|
|
98
|
+
const client = new P2PClient('http://old-host:18080', 'tok');
|
|
99
|
+
client.setBaseUrl('http://new-host:18080');
|
|
100
|
+
expect(client.getArtifactUrl('b1')).toBe(
|
|
101
|
+
'http://new-host:18080/builds/b1/artifact'
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('strips trailing slash from new URL', () => {
|
|
106
|
+
const client = new P2PClient('http://old:18080', 'tok');
|
|
107
|
+
client.setBaseUrl('http://new:18080/');
|
|
108
|
+
expect(client.getArtifactUrl('b1')).toBe(
|
|
109
|
+
'http://new:18080/builds/b1/artifact'
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('info()', () => {
|
|
115
|
+
it('returns agent info on success', async () => {
|
|
116
|
+
mockFetch.mockResolvedValue({
|
|
117
|
+
ok: true,
|
|
118
|
+
json: () => Promise.resolve({ hostname: 'MacBook', version: '1.44.0', platform: 'darwin' }),
|
|
119
|
+
text: () => Promise.resolve(''),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const client = new P2PClient('http://localhost:18080', 'tok');
|
|
123
|
+
const info = await client.info();
|
|
124
|
+
expect(info.hostname).toBe('MacBook');
|
|
125
|
+
expect(info.version).toBe('1.44.0');
|
|
126
|
+
expect(info.platform).toBe('darwin');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('sends Authorization header', async () => {
|
|
130
|
+
mockFetch.mockResolvedValue({
|
|
131
|
+
ok: true,
|
|
132
|
+
json: () => Promise.resolve({ hostname: 'test' }),
|
|
133
|
+
text: () => Promise.resolve(''),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const client = new P2PClient('http://localhost:18080', 'my-secret-token');
|
|
137
|
+
await client.info();
|
|
138
|
+
|
|
139
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
140
|
+
'http://localhost:18080/health',
|
|
141
|
+
expect.objectContaining({
|
|
142
|
+
headers: expect.objectContaining({
|
|
143
|
+
Authorization: 'Bearer my-secret-token',
|
|
144
|
+
}),
|
|
145
|
+
})
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('uploadFeedback()', () => {
|
|
151
|
+
it('sends a POST request to /feedback', async () => {
|
|
152
|
+
mockFetch.mockResolvedValue({
|
|
153
|
+
ok: true,
|
|
154
|
+
json: () => Promise.resolve({ id: 'report-456' }),
|
|
155
|
+
text: () => Promise.resolve(''),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const client = new P2PClient('http://localhost:18080', 'tok');
|
|
159
|
+
const bundle = {
|
|
160
|
+
metadata: {
|
|
161
|
+
timestamp: '2026-03-24T00:00:00Z',
|
|
162
|
+
device: {
|
|
163
|
+
platform: 'ios',
|
|
164
|
+
osVersion: '18.0',
|
|
165
|
+
model: 'iPhone 16',
|
|
166
|
+
screenWidth: 393,
|
|
167
|
+
screenHeight: 852,
|
|
168
|
+
},
|
|
169
|
+
app: { bundleId: 'com.test', version: '1.0' },
|
|
170
|
+
userNote: 'Button is broken',
|
|
171
|
+
},
|
|
172
|
+
screenshots: [],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const reportId = await client.uploadFeedback(bundle);
|
|
176
|
+
expect(reportId).toBe('report-456');
|
|
177
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
178
|
+
'http://localhost:18080/feedback',
|
|
179
|
+
expect.objectContaining({ method: 'POST' })
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('throws on non-ok response', async () => {
|
|
184
|
+
mockFetch.mockResolvedValue({
|
|
185
|
+
ok: false,
|
|
186
|
+
status: 500,
|
|
187
|
+
text: () => Promise.resolve('Internal Server Error'),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const client = new P2PClient('http://localhost:18080', 'tok');
|
|
191
|
+
const bundle = {
|
|
192
|
+
metadata: {
|
|
193
|
+
timestamp: '2026-03-24T00:00:00Z',
|
|
194
|
+
device: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
|
|
195
|
+
app: {},
|
|
196
|
+
},
|
|
197
|
+
screenshots: [],
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
await expect(client.uploadFeedback(bundle)).rejects.toThrow('Upload failed');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('listBuilds()', () => {
|
|
205
|
+
it('returns builds array on success', async () => {
|
|
206
|
+
const builds = [{ id: '1', platform: 'ios', status: 'complete' }];
|
|
207
|
+
mockFetch.mockResolvedValue({
|
|
208
|
+
ok: true,
|
|
209
|
+
json: () => Promise.resolve({ builds }),
|
|
210
|
+
text: () => Promise.resolve(''),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const client = new P2PClient('http://localhost:18080', 'tok');
|
|
214
|
+
const result = await client.listBuilds();
|
|
215
|
+
expect(result).toEqual(builds);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { P2PClient } from '../P2PClient';
|
|
2
|
+
|
|
3
|
+
// Mock react-native Platform
|
|
4
|
+
jest.mock('react-native', () => ({
|
|
5
|
+
Platform: { OS: 'ios' },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
// Mock fetch globally
|
|
9
|
+
const mockFetch = jest.fn();
|
|
10
|
+
global.fetch = mockFetch as any;
|
|
11
|
+
|
|
12
|
+
// Mock AbortController
|
|
13
|
+
class MockAbortController {
|
|
14
|
+
signal = {};
|
|
15
|
+
abort = jest.fn();
|
|
16
|
+
}
|
|
17
|
+
global.AbortController = MockAbortController as any;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
jest.clearAllMocks();
|
|
21
|
+
mockFetch.mockReset();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('SDK Token Auth', () => {
|
|
25
|
+
describe('Bearer token in requests', () => {
|
|
26
|
+
it('sends SDK token in Authorization header for feedback', async () => {
|
|
27
|
+
mockFetch.mockResolvedValue({
|
|
28
|
+
ok: true,
|
|
29
|
+
json: () => Promise.resolve({ id: 'report-1' }),
|
|
30
|
+
text: () => Promise.resolve(''),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const client = new P2PClient('http://192.168.1.10:18080', 'sdk-token-abc123');
|
|
34
|
+
const bundle = {
|
|
35
|
+
metadata: {
|
|
36
|
+
timestamp: '2026-03-25T00:00:00Z',
|
|
37
|
+
device: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
|
|
38
|
+
app: {},
|
|
39
|
+
},
|
|
40
|
+
screenshots: [],
|
|
41
|
+
};
|
|
42
|
+
await client.uploadFeedback(bundle);
|
|
43
|
+
|
|
44
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
45
|
+
'http://192.168.1.10:18080/feedback',
|
|
46
|
+
expect.objectContaining({
|
|
47
|
+
headers: expect.objectContaining({
|
|
48
|
+
Authorization: 'Bearer sdk-token-abc123',
|
|
49
|
+
}),
|
|
50
|
+
})
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('sends SDK token in Authorization header for voice', async () => {
|
|
55
|
+
mockFetch.mockResolvedValue({
|
|
56
|
+
ok: true,
|
|
57
|
+
json: () => Promise.resolve({ voiceInputEnabled: true }),
|
|
58
|
+
text: () => Promise.resolve(''),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const client = new P2PClient('http://localhost:18080', 'my-sdk-token');
|
|
62
|
+
await client.voiceStatus();
|
|
63
|
+
|
|
64
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
65
|
+
'http://localhost:18080/voice/status',
|
|
66
|
+
expect.objectContaining({
|
|
67
|
+
headers: expect.objectContaining({
|
|
68
|
+
Authorization: 'Bearer my-sdk-token',
|
|
69
|
+
}),
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('sends SDK token for test session', async () => {
|
|
75
|
+
mockFetch.mockResolvedValue({
|
|
76
|
+
ok: true,
|
|
77
|
+
json: () => Promise.resolve({ sessionId: 'sess-1' }),
|
|
78
|
+
text: () => Promise.resolve(''),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const client = new P2PClient('http://localhost:18080', 'test-sdk-token');
|
|
82
|
+
await client.startTestSession();
|
|
83
|
+
|
|
84
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
85
|
+
'http://localhost:18080/test-app/start',
|
|
86
|
+
expect.objectContaining({
|
|
87
|
+
headers: expect.objectContaining({
|
|
88
|
+
Authorization: 'Bearer test-sdk-token',
|
|
89
|
+
}),
|
|
90
|
+
})
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('setAuthToken()', () => {
|
|
96
|
+
it('updates token for subsequent requests', async () => {
|
|
97
|
+
mockFetch.mockResolvedValue({
|
|
98
|
+
ok: true,
|
|
99
|
+
json: () => Promise.resolve({ hostname: 'test' }),
|
|
100
|
+
text: () => Promise.resolve(''),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const client = new P2PClient('http://localhost:18080', 'old-token');
|
|
104
|
+
client.setAuthToken('new-sdk-token');
|
|
105
|
+
await client.info();
|
|
106
|
+
|
|
107
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
108
|
+
expect.any(String),
|
|
109
|
+
expect.objectContaining({
|
|
110
|
+
headers: expect.objectContaining({
|
|
111
|
+
Authorization: 'Bearer new-sdk-token',
|
|
112
|
+
}),
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('Error handling for SDK token auth failures', () => {
|
|
119
|
+
it('throws on 403 Forbidden (scope violation)', async () => {
|
|
120
|
+
mockFetch.mockResolvedValue({
|
|
121
|
+
ok: false,
|
|
122
|
+
status: 403,
|
|
123
|
+
text: () => Promise.resolve('SDK token scope does not allow this endpoint'),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const client = new P2PClient('http://localhost:18080', 'limited-sdk');
|
|
127
|
+
await expect(client.info()).rejects.toThrow('403');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('throws on 403 Forbidden (IP binding)', async () => {
|
|
131
|
+
mockFetch.mockResolvedValue({
|
|
132
|
+
ok: false,
|
|
133
|
+
status: 403,
|
|
134
|
+
text: () => Promise.resolve('SDK token not allowed from this IP'),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const client = new P2PClient('http://localhost:18080', 'ip-bound-sdk');
|
|
138
|
+
await expect(client.info()).rejects.toThrow('403');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('throws on 401 Unauthorized (expired token)', async () => {
|
|
142
|
+
mockFetch.mockResolvedValue({
|
|
143
|
+
ok: false,
|
|
144
|
+
status: 401,
|
|
145
|
+
text: () => Promise.resolve('Invalid or expired SDK token'),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const client = new P2PClient('http://localhost:18080', 'expired-sdk');
|
|
149
|
+
await expect(client.info()).rejects.toThrow('401');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('Token rotation', () => {
|
|
154
|
+
it('rotateToken() calls POST /sdk/token/rotate', async () => {
|
|
155
|
+
mockFetch.mockResolvedValue({
|
|
156
|
+
ok: true,
|
|
157
|
+
json: () => Promise.resolve({
|
|
158
|
+
token: 'new-rotated-token',
|
|
159
|
+
expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000,
|
|
160
|
+
}),
|
|
161
|
+
text: () => Promise.resolve(''),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const client = new P2PClient('http://localhost:18080', 'current-sdk-token');
|
|
165
|
+
const result = await client.rotateToken();
|
|
166
|
+
|
|
167
|
+
expect(result.token).toBe('new-rotated-token');
|
|
168
|
+
expect(result.expiresAt).toBeGreaterThan(Date.now());
|
|
169
|
+
|
|
170
|
+
// Verify the request was made correctly
|
|
171
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
172
|
+
'http://localhost:18080/sdk/token/rotate',
|
|
173
|
+
expect.objectContaining({
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: expect.objectContaining({
|
|
176
|
+
Authorization: 'Bearer current-sdk-token',
|
|
177
|
+
}),
|
|
178
|
+
})
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('rotateToken() auto-updates internal token', async () => {
|
|
183
|
+
mockFetch.mockResolvedValueOnce({
|
|
184
|
+
ok: true,
|
|
185
|
+
json: () => Promise.resolve({
|
|
186
|
+
token: 'rotated-token-xyz',
|
|
187
|
+
expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000,
|
|
188
|
+
}),
|
|
189
|
+
text: () => Promise.resolve(''),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const client = new P2PClient('http://localhost:18080', 'old-token');
|
|
193
|
+
await client.rotateToken();
|
|
194
|
+
|
|
195
|
+
// Now make another request — should use the new token
|
|
196
|
+
mockFetch.mockResolvedValueOnce({
|
|
197
|
+
ok: true,
|
|
198
|
+
json: () => Promise.resolve({ hostname: 'test' }),
|
|
199
|
+
text: () => Promise.resolve(''),
|
|
200
|
+
});
|
|
201
|
+
await client.info();
|
|
202
|
+
|
|
203
|
+
// Second call should use the rotated token
|
|
204
|
+
expect(mockFetch).toHaveBeenLastCalledWith(
|
|
205
|
+
expect.any(String),
|
|
206
|
+
expect.objectContaining({
|
|
207
|
+
headers: expect.objectContaining({
|
|
208
|
+
Authorization: 'Bearer rotated-token-xyz',
|
|
209
|
+
}),
|
|
210
|
+
})
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('rotateToken() throws on failure', async () => {
|
|
215
|
+
mockFetch.mockResolvedValue({
|
|
216
|
+
ok: false,
|
|
217
|
+
status: 401,
|
|
218
|
+
text: () => Promise.resolve('Token already rotated'),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const client = new P2PClient('http://localhost:18080', 'already-rotated');
|
|
222
|
+
await expect(client.rotateToken()).rejects.toThrow('Token rotation failed');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('Health endpoint with TLS info', () => {
|
|
227
|
+
it('health response includes TLS fingerprint', async () => {
|
|
228
|
+
mockFetch.mockResolvedValue({
|
|
229
|
+
ok: true,
|
|
230
|
+
json: () => Promise.resolve({
|
|
231
|
+
ok: true,
|
|
232
|
+
hostname: 'MacBook',
|
|
233
|
+
version: '1.45.0',
|
|
234
|
+
tlsFingerprint: 'abc123def456789',
|
|
235
|
+
tlsPort: 18443,
|
|
236
|
+
}),
|
|
237
|
+
text: () => Promise.resolve(''),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const client = new P2PClient('http://localhost:18080', 'tok');
|
|
241
|
+
const info = await client.info();
|
|
242
|
+
expect(info.hostname).toBe('MacBook');
|
|
243
|
+
expect(info.version).toBe('1.45.0');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('Multiple SDK token scenarios', () => {
|
|
248
|
+
it('works with long token strings', async () => {
|
|
249
|
+
const longToken = 'a'.repeat(64); // 64-char hex token
|
|
250
|
+
mockFetch.mockResolvedValue({ ok: true });
|
|
251
|
+
|
|
252
|
+
const client = new P2PClient('http://localhost:18080', longToken);
|
|
253
|
+
const healthy = await client.health();
|
|
254
|
+
expect(healthy).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('health check does not send auth header', async () => {
|
|
258
|
+
mockFetch.mockResolvedValue({ ok: true });
|
|
259
|
+
|
|
260
|
+
const client = new P2PClient('http://localhost:18080', 'secret-sdk');
|
|
261
|
+
await client.health();
|
|
262
|
+
|
|
263
|
+
// health() should NOT send Authorization header
|
|
264
|
+
const call = mockFetch.mock.calls[0];
|
|
265
|
+
expect(call[1].headers).toBeUndefined();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
});
|