@agent-relay/dashboard 2.0.92 → 2.0.93
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/out/404.html +1 -1
- package/out/_next/static/chunks/{5518-6d77237eefc8d5ae.js → 5518-3b96a248632a79c0.js} +1 -1
- package/out/about.html +1 -1
- package/out/about.txt +1 -1
- package/out/app/onboarding.html +1 -1
- package/out/app/onboarding.txt +1 -1
- package/out/app.html +1 -1
- package/out/app.txt +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +1 -1
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
- package/out/blog/let-them-cook-multi-agent-orchestration.html +1 -1
- package/out/blog/let-them-cook-multi-agent-orchestration.txt +1 -1
- package/out/blog.html +1 -1
- package/out/blog.txt +1 -1
- package/out/careers.html +1 -1
- package/out/careers.txt +1 -1
- package/out/changelog.html +1 -1
- package/out/changelog.txt +1 -1
- package/out/cloud/link.html +1 -1
- package/out/cloud/link.txt +1 -1
- package/out/complete-profile.html +1 -1
- package/out/complete-profile.txt +1 -1
- package/out/connect-repos.html +1 -1
- package/out/connect-repos.txt +1 -1
- package/out/contact.html +1 -1
- package/out/contact.txt +1 -1
- package/out/dev/cli-tools.html +1 -1
- package/out/dev/cli-tools.txt +1 -1
- package/out/dev/log-viewer.html +1 -1
- package/out/dev/log-viewer.txt +1 -1
- package/out/docs.html +1 -1
- package/out/docs.txt +1 -1
- package/out/history.html +1 -1
- package/out/history.txt +1 -1
- package/out/index.html +1 -1
- package/out/index.txt +2 -2
- package/out/login.html +1 -1
- package/out/login.txt +1 -1
- package/out/metrics.html +1 -1
- package/out/metrics.txt +1 -1
- package/out/pricing.html +1 -1
- package/out/pricing.txt +1 -1
- package/out/privacy.html +1 -1
- package/out/privacy.txt +1 -1
- package/out/providers/setup/claude.html +1 -1
- package/out/providers/setup/claude.txt +1 -1
- package/out/providers/setup/codex.html +1 -1
- package/out/providers/setup/codex.txt +1 -1
- package/out/providers/setup/cursor.html +1 -1
- package/out/providers/setup/cursor.txt +1 -1
- package/out/providers.html +1 -1
- package/out/providers.txt +1 -1
- package/out/security.html +1 -1
- package/out/security.txt +1 -1
- package/out/signup.html +1 -1
- package/out/signup.txt +1 -1
- package/out/terms.html +1 -1
- package/out/terms.txt +1 -1
- package/package.json +1 -1
- package/src/components/hooks/useOrchestrator.test.ts +3 -3
- package/src/providers/RelayConfigProvider.integration.test.tsx +549 -0
- package/src/providers/RelayConfigProvider.test.tsx +299 -0
- package/src/providers/RelayConfigProvider.tsx +198 -16
- /package/out/_next/static/{5cqIVzlh9DbJT28EbNrcC → 9CykW6n4dJn75_XoFVN_X}/_buildManifest.js +0 -0
- /package/out/_next/static/{5cqIVzlh9DbJT28EbNrcC → 9CykW6n4dJn75_XoFVN_X}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { createServer as createHttpServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from 'http';
|
|
9
|
+
import React, { useEffect } from 'react';
|
|
10
|
+
import { act, cleanup, render, waitFor } from '@testing-library/react';
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { useChannels, useReply, useSendMessage, useWebSocket } from '@relaycast/react';
|
|
13
|
+
import { createServer, type DashboardServer } from '../../../dashboard-server/src/proxy-server.js';
|
|
14
|
+
import { RelayConfigProvider, useRelayConfigStatus } from './RelayConfigProvider';
|
|
15
|
+
|
|
16
|
+
interface HarnessApi {
|
|
17
|
+
channelNames: string[];
|
|
18
|
+
channelsLoading: boolean;
|
|
19
|
+
connectionStatus: string;
|
|
20
|
+
send: (channel: string, text: string) => Promise<{ id: string }>;
|
|
21
|
+
reply: (messageId: string, text: string) => Promise<{ id: string }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface FakeMessage {
|
|
25
|
+
id: string;
|
|
26
|
+
channel_id: string;
|
|
27
|
+
agent_id: string;
|
|
28
|
+
agent_name: string;
|
|
29
|
+
text: string;
|
|
30
|
+
blocks: null;
|
|
31
|
+
has_attachments: boolean;
|
|
32
|
+
thread_id: string | null;
|
|
33
|
+
attachments: [];
|
|
34
|
+
created_at: string;
|
|
35
|
+
reply_count: number;
|
|
36
|
+
reactions: [];
|
|
37
|
+
read_by_count: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class MockWebSocket {
|
|
41
|
+
static CONNECTING = 0;
|
|
42
|
+
static OPEN = 1;
|
|
43
|
+
static CLOSING = 2;
|
|
44
|
+
static CLOSED = 3;
|
|
45
|
+
static instances: MockWebSocket[] = [];
|
|
46
|
+
|
|
47
|
+
readonly url: string;
|
|
48
|
+
readonly sentFrames: string[] = [];
|
|
49
|
+
readyState = MockWebSocket.CONNECTING;
|
|
50
|
+
onopen: (() => void) | null = null;
|
|
51
|
+
onclose: (() => void) | null = null;
|
|
52
|
+
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
53
|
+
onerror: (() => void) | null = null;
|
|
54
|
+
|
|
55
|
+
constructor(url: string) {
|
|
56
|
+
this.url = url;
|
|
57
|
+
MockWebSocket.instances.push(this);
|
|
58
|
+
|
|
59
|
+
queueMicrotask(() => {
|
|
60
|
+
if (this.readyState !== MockWebSocket.CONNECTING) return;
|
|
61
|
+
this.readyState = MockWebSocket.OPEN;
|
|
62
|
+
this.onopen?.();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
send(data: string): void {
|
|
67
|
+
this.sentFrames.push(data);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
close(): void {
|
|
71
|
+
if (this.readyState === MockWebSocket.CLOSED) return;
|
|
72
|
+
this.readyState = MockWebSocket.CLOSED;
|
|
73
|
+
this.onclose?.();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
class FakeRelaycastBackend {
|
|
78
|
+
readonly workspaceApiKey = 'rk_workspace';
|
|
79
|
+
readonly channelListAuthTokens: string[] = [];
|
|
80
|
+
readonly sendAuthTokens: string[] = [];
|
|
81
|
+
readonly replyAuthTokens: string[] = [];
|
|
82
|
+
|
|
83
|
+
private server: HttpServer;
|
|
84
|
+
private agent:
|
|
85
|
+
| {
|
|
86
|
+
id: string;
|
|
87
|
+
name: string;
|
|
88
|
+
type: 'human';
|
|
89
|
+
status: 'online';
|
|
90
|
+
createdAt: string;
|
|
91
|
+
lastSeen: string;
|
|
92
|
+
token: string;
|
|
93
|
+
}
|
|
94
|
+
| null = null;
|
|
95
|
+
private invalidTokens = new Set<string>();
|
|
96
|
+
private tokenCounter = 0;
|
|
97
|
+
private messageCounter = 0;
|
|
98
|
+
|
|
99
|
+
constructor() {
|
|
100
|
+
this.server = createHttpServer((req, res) => {
|
|
101
|
+
void this.handle(req, res);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get baseUrl(): string {
|
|
106
|
+
const address = this.server.address();
|
|
107
|
+
if (!address || typeof address === 'string') {
|
|
108
|
+
throw new Error('Fake Relaycast server address not available');
|
|
109
|
+
}
|
|
110
|
+
return `http://127.0.0.1:${address.port}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get currentAgentToken(): string | null {
|
|
114
|
+
return this.agent?.token ?? null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async start(): Promise<void> {
|
|
118
|
+
await listen(this.server);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async stop(): Promise<void> {
|
|
122
|
+
await closeHttpServer(this.server);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
invalidateCurrentAgentToken(): void {
|
|
126
|
+
if (!this.agent) {
|
|
127
|
+
throw new Error('Cannot invalidate agent token before registration');
|
|
128
|
+
}
|
|
129
|
+
this.invalidTokens.add(this.agent.token);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
133
|
+
const url = new URL(req.url ?? '/', this.baseUrl);
|
|
134
|
+
const method = req.method ?? 'GET';
|
|
135
|
+
const authToken = readBearerToken(req.headers.authorization);
|
|
136
|
+
|
|
137
|
+
if (method === 'POST' && url.pathname === '/v1/agents') {
|
|
138
|
+
if (!this.isWorkspaceAuthorized(authToken)) {
|
|
139
|
+
sendJson(res, 401, { ok: false, error: { code: 'unauthorized', message: 'workspace key required' } });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const body = await readJson(req);
|
|
144
|
+
const requestedName = typeof body?.name === 'string' ? body.name : '';
|
|
145
|
+
if (!requestedName) {
|
|
146
|
+
sendJson(res, 400, { ok: false, error: { code: 'invalid_request', message: 'name is required' } });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (this.agent) {
|
|
151
|
+
sendJson(res, 409, { ok: false, error: { code: 'agent_already_exists', message: 'agent already exists' } });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const createdAt = new Date().toISOString();
|
|
156
|
+
this.agent = {
|
|
157
|
+
id: 'agent-1',
|
|
158
|
+
name: requestedName,
|
|
159
|
+
type: 'human',
|
|
160
|
+
status: 'online',
|
|
161
|
+
createdAt,
|
|
162
|
+
lastSeen: createdAt,
|
|
163
|
+
token: this.issueToken(),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
sendJson(res, 200, {
|
|
167
|
+
ok: true,
|
|
168
|
+
data: {
|
|
169
|
+
id: this.agent.id,
|
|
170
|
+
name: this.agent.name,
|
|
171
|
+
token: this.agent.token,
|
|
172
|
+
status: this.agent.status,
|
|
173
|
+
created_at: this.agent.createdAt,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (method === 'GET' && url.pathname.startsWith('/v1/agents/')) {
|
|
180
|
+
if (!this.isWorkspaceAuthorized(authToken)) {
|
|
181
|
+
sendJson(res, 401, { ok: false, error: { code: 'unauthorized', message: 'workspace key required' } });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const name = decodeURIComponent(url.pathname.slice('/v1/agents/'.length));
|
|
186
|
+
if (!this.agent || this.agent.name !== name) {
|
|
187
|
+
sendJson(res, 404, { ok: false, error: { code: 'agent_not_found', message: 'agent not found' } });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
sendJson(res, 200, {
|
|
192
|
+
ok: true,
|
|
193
|
+
data: {
|
|
194
|
+
id: this.agent.id,
|
|
195
|
+
name: this.agent.name,
|
|
196
|
+
type: this.agent.type,
|
|
197
|
+
status: this.agent.status,
|
|
198
|
+
persona: null,
|
|
199
|
+
metadata: {},
|
|
200
|
+
last_seen: this.agent.lastSeen,
|
|
201
|
+
created_at: this.agent.createdAt,
|
|
202
|
+
channels: [],
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (method === 'POST' && url.pathname.endsWith('/rotate-token')) {
|
|
209
|
+
if (!this.isWorkspaceAuthorized(authToken)) {
|
|
210
|
+
sendJson(res, 401, { ok: false, error: { code: 'unauthorized', message: 'workspace key required' } });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!this.agent) {
|
|
215
|
+
sendJson(res, 404, { ok: false, error: { code: 'agent_not_found', message: 'agent not found' } });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
this.invalidTokens.add(this.agent.token);
|
|
220
|
+
this.agent.token = this.issueToken();
|
|
221
|
+
this.agent.lastSeen = new Date().toISOString();
|
|
222
|
+
|
|
223
|
+
sendJson(res, 200, {
|
|
224
|
+
ok: true,
|
|
225
|
+
data: {
|
|
226
|
+
token: this.agent.token,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (method === 'GET' && url.pathname === '/v1/channels') {
|
|
233
|
+
this.channelListAuthTokens.push(authToken ?? '');
|
|
234
|
+
if (!this.isCurrentAgentToken(authToken)) {
|
|
235
|
+
sendJson(res, 401, { ok: false, error: { code: 'unauthorized', message: 'stale token' } });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
sendJson(res, 200, {
|
|
240
|
+
ok: true,
|
|
241
|
+
data: [
|
|
242
|
+
{
|
|
243
|
+
id: 'channel-1',
|
|
244
|
+
name: 'general',
|
|
245
|
+
topic: null,
|
|
246
|
+
created_at: this.agent?.createdAt ?? new Date().toISOString(),
|
|
247
|
+
created_by: this.agent?.id ?? null,
|
|
248
|
+
is_archived: false,
|
|
249
|
+
member_count: 1,
|
|
250
|
+
members: [],
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (method === 'POST' && url.pathname === '/v1/channels/general/join') {
|
|
258
|
+
if (!this.isCurrentAgentToken(authToken)) {
|
|
259
|
+
sendJson(res, 401, { ok: false, error: { code: 'unauthorized', message: 'stale token' } });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
sendJson(res, 200, {
|
|
264
|
+
ok: true,
|
|
265
|
+
data: {
|
|
266
|
+
channel: 'general',
|
|
267
|
+
agent_id: this.agent?.id ?? 'agent-1',
|
|
268
|
+
already_member: true,
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (method === 'POST' && url.pathname === '/v1/channels/general/messages') {
|
|
275
|
+
this.sendAuthTokens.push(authToken ?? '');
|
|
276
|
+
if (!this.isCurrentAgentToken(authToken)) {
|
|
277
|
+
sendJson(res, 401, { ok: false, error: { code: 'unauthorized', message: 'stale token' } });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const body = await readJson(req);
|
|
282
|
+
sendJson(res, 200, {
|
|
283
|
+
ok: true,
|
|
284
|
+
data: this.createMessage(String(body?.text ?? ''), null),
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const replyMatch = url.pathname.match(/^\/v1\/messages\/([^/]+)\/replies$/);
|
|
290
|
+
if (method === 'POST' && replyMatch) {
|
|
291
|
+
this.replyAuthTokens.push(authToken ?? '');
|
|
292
|
+
if (!this.isCurrentAgentToken(authToken)) {
|
|
293
|
+
sendJson(res, 401, { ok: false, error: { code: 'unauthorized', message: 'stale token' } });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const body = await readJson(req);
|
|
298
|
+
const parentId = decodeURIComponent(replyMatch[1] ?? '');
|
|
299
|
+
sendJson(res, 200, {
|
|
300
|
+
ok: true,
|
|
301
|
+
data: this.createMessage(String(body?.text ?? ''), parentId),
|
|
302
|
+
});
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
sendJson(res, 404, { ok: false, error: { code: 'not_found', message: `${method} ${url.pathname} not mocked` } });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private createMessage(text: string, threadId: string | null): FakeMessage {
|
|
310
|
+
this.messageCounter += 1;
|
|
311
|
+
const createdAt = new Date().toISOString();
|
|
312
|
+
return {
|
|
313
|
+
id: `msg-${this.messageCounter}`,
|
|
314
|
+
channel_id: 'channel-1',
|
|
315
|
+
agent_id: this.agent?.id ?? 'agent-1',
|
|
316
|
+
agent_name: this.agent?.name ?? 'dashboard',
|
|
317
|
+
text,
|
|
318
|
+
blocks: null,
|
|
319
|
+
has_attachments: false,
|
|
320
|
+
thread_id: threadId,
|
|
321
|
+
attachments: [],
|
|
322
|
+
created_at: createdAt,
|
|
323
|
+
reply_count: 0,
|
|
324
|
+
reactions: [],
|
|
325
|
+
read_by_count: 0,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private issueToken(): string {
|
|
330
|
+
this.tokenCounter += 1;
|
|
331
|
+
return `agt_${this.tokenCounter}`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private isWorkspaceAuthorized(token: string | null): boolean {
|
|
335
|
+
return token === this.workspaceApiKey;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private isCurrentAgentToken(token: string | null): boolean {
|
|
339
|
+
return Boolean(token && this.agent && token === this.agent.token && !this.invalidTokens.has(token));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function readBearerToken(header: string | string[] | undefined): string | null {
|
|
344
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
345
|
+
if (!value?.startsWith('Bearer ')) return null;
|
|
346
|
+
return value.slice(7);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function readJson(req: IncomingMessage): Promise<Record<string, unknown> | null> {
|
|
350
|
+
const chunks: Buffer[] = [];
|
|
351
|
+
for await (const chunk of req) {
|
|
352
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (chunks.length === 0) return null;
|
|
356
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf-8')) as Record<string, unknown>;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void {
|
|
360
|
+
res.statusCode = statusCode;
|
|
361
|
+
res.setHeader('Content-Type', 'application/json');
|
|
362
|
+
res.end(JSON.stringify(payload));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function listen(server: HttpServer): Promise<void> {
|
|
366
|
+
await new Promise<void>((resolve, reject) => {
|
|
367
|
+
server.once('error', reject);
|
|
368
|
+
server.listen(0, () => {
|
|
369
|
+
server.off('error', reject);
|
|
370
|
+
resolve();
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function closeHttpServer(server: HttpServer): Promise<void> {
|
|
376
|
+
if (!server.listening) return;
|
|
377
|
+
|
|
378
|
+
await new Promise<void>((resolve, reject) => {
|
|
379
|
+
server.close((error) => {
|
|
380
|
+
if (error) {
|
|
381
|
+
reject(error);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
resolve();
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function extractWebSocketToken(url: string): string | null {
|
|
390
|
+
return new URL(url).searchParams.get('token');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function RelayHarness({ apiRef }: { apiRef: { current: HarnessApi | null } }) {
|
|
394
|
+
const relayConfig = useRelayConfigStatus();
|
|
395
|
+
|
|
396
|
+
if (!relayConfig.configured || relayConfig.loading) {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return <RelayHarnessBody apiRef={apiRef} />;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function RelayHarnessBody({ apiRef }: { apiRef: { current: HarnessApi | null } }) {
|
|
404
|
+
const { channels, loading } = useChannels();
|
|
405
|
+
const { send } = useSendMessage();
|
|
406
|
+
const { reply } = useReply();
|
|
407
|
+
const { status } = useWebSocket();
|
|
408
|
+
|
|
409
|
+
useEffect(() => {
|
|
410
|
+
apiRef.current = {
|
|
411
|
+
channelNames: (channels ?? []).map((channel) => channel.name),
|
|
412
|
+
channelsLoading: loading,
|
|
413
|
+
connectionStatus: status,
|
|
414
|
+
send: async (channel: string, text: string) => {
|
|
415
|
+
return send(channel, text) as Promise<{ id: string }>;
|
|
416
|
+
},
|
|
417
|
+
reply: async (messageId: string, text: string) => {
|
|
418
|
+
return reply(messageId, text) as Promise<{ id: string }>;
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}, [apiRef, channels, loading, reply, send, status]);
|
|
422
|
+
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
describe('RelayConfigProvider integration', () => {
|
|
427
|
+
const originalFetch = globalThis.fetch.bind(globalThis);
|
|
428
|
+
const originalRelaycastApiUrl = process.env.RELAYCAST_API_URL;
|
|
429
|
+
let backend: FakeRelaycastBackend;
|
|
430
|
+
let dashboard: DashboardServer;
|
|
431
|
+
let dataDir: string;
|
|
432
|
+
let staticDir: string;
|
|
433
|
+
|
|
434
|
+
beforeEach(async () => {
|
|
435
|
+
cleanup();
|
|
436
|
+
MockWebSocket.instances.length = 0;
|
|
437
|
+
backend = new FakeRelaycastBackend();
|
|
438
|
+
await backend.start();
|
|
439
|
+
|
|
440
|
+
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-config-integration-data-'));
|
|
441
|
+
staticDir = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-config-integration-static-'));
|
|
442
|
+
fs.writeFileSync(path.join(staticDir, 'app.html'), '<!doctype html><h1>dashboard</h1>', 'utf-8');
|
|
443
|
+
|
|
444
|
+
process.env.RELAYCAST_API_URL = backend.baseUrl;
|
|
445
|
+
|
|
446
|
+
dashboard = createServer({
|
|
447
|
+
port: 0,
|
|
448
|
+
mock: false,
|
|
449
|
+
verbose: false,
|
|
450
|
+
dataDir,
|
|
451
|
+
staticDir,
|
|
452
|
+
relayApiKey: backend.workspaceApiKey,
|
|
453
|
+
});
|
|
454
|
+
await listen(dashboard.server);
|
|
455
|
+
|
|
456
|
+
const address = dashboard.server.address();
|
|
457
|
+
if (!address || typeof address === 'string') {
|
|
458
|
+
throw new Error('Dashboard server address not available');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const dashboardOrigin = `http://127.0.0.1:${address.port}`;
|
|
462
|
+
vi.stubGlobal('WebSocket', MockWebSocket);
|
|
463
|
+
vi.stubGlobal('fetch', ((input: RequestInfo | URL, init?: RequestInit) => {
|
|
464
|
+
const url = typeof input === 'string'
|
|
465
|
+
? input
|
|
466
|
+
: input instanceof URL
|
|
467
|
+
? input.toString()
|
|
468
|
+
: input.url;
|
|
469
|
+
if (url.startsWith('/')) {
|
|
470
|
+
return originalFetch(new URL(url, dashboardOrigin), init);
|
|
471
|
+
}
|
|
472
|
+
return originalFetch(input, init);
|
|
473
|
+
}) as typeof globalThis.fetch);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
afterEach(async () => {
|
|
477
|
+
cleanup();
|
|
478
|
+
vi.unstubAllGlobals();
|
|
479
|
+
process.env.RELAYCAST_API_URL = originalRelaycastApiUrl;
|
|
480
|
+
await dashboard?.close();
|
|
481
|
+
await backend?.stop();
|
|
482
|
+
if (dataDir) {
|
|
483
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
484
|
+
}
|
|
485
|
+
if (staticDir) {
|
|
486
|
+
fs.rmSync(staticDir, { recursive: true, force: true });
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('reloads channels and keeps send/reply working after the dashboard agent token is invalidated', async () => {
|
|
491
|
+
const apiRef: { current: HarnessApi | null } = { current: null };
|
|
492
|
+
|
|
493
|
+
render(
|
|
494
|
+
<RelayConfigProvider>
|
|
495
|
+
<RelayHarness apiRef={apiRef} />
|
|
496
|
+
</RelayConfigProvider>,
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
await waitFor(() => {
|
|
500
|
+
expect(apiRef.current).not.toBeNull();
|
|
501
|
+
expect(apiRef.current?.channelsLoading).toBe(false);
|
|
502
|
+
expect(apiRef.current?.channelNames).toEqual(['general']);
|
|
503
|
+
expect(apiRef.current?.connectionStatus).toBe('connected');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const initialToken = backend.currentAgentToken;
|
|
507
|
+
expect(initialToken).toBe('agt_1');
|
|
508
|
+
expect(backend.channelListAuthTokens).toContain('agt_1');
|
|
509
|
+
|
|
510
|
+
backend.invalidateCurrentAgentToken();
|
|
511
|
+
|
|
512
|
+
let sentMessage: { id: string } | undefined;
|
|
513
|
+
await act(async () => {
|
|
514
|
+
sentMessage = await apiRef.current!.send('general', 'hello after rotation');
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
await waitFor(() => {
|
|
518
|
+
expect(backend.currentAgentToken).toBe('agt_2');
|
|
519
|
+
expect(apiRef.current?.channelNames).toEqual(['general']);
|
|
520
|
+
expect(apiRef.current?.channelsLoading).toBe(false);
|
|
521
|
+
expect(apiRef.current?.connectionStatus).toBe('connected');
|
|
522
|
+
expect(backend.channelListAuthTokens).toContain('agt_2');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
expect(sentMessage?.id).toBe('msg-1');
|
|
526
|
+
|
|
527
|
+
let replyMessage: { id: string } | undefined;
|
|
528
|
+
await act(async () => {
|
|
529
|
+
replyMessage = await apiRef.current!.reply(sentMessage!.id, 'reply after refresh');
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
expect(replyMessage?.id).toBe('msg-2');
|
|
533
|
+
expect(backend.sendAuthTokens).toEqual(['agt_1', 'agt_2']);
|
|
534
|
+
expect(backend.replyAuthTokens.at(-1)).toBe('agt_2');
|
|
535
|
+
|
|
536
|
+
const configuredWebSocketTokens = MockWebSocket.instances
|
|
537
|
+
.map((socket) => extractWebSocketToken(socket.url))
|
|
538
|
+
.filter((token): token is string => Boolean(token && token !== '__relay_disabled__'));
|
|
539
|
+
expect(configuredWebSocketTokens.length).toBeGreaterThanOrEqual(2);
|
|
540
|
+
expect(new Set(configuredWebSocketTokens)).toEqual(new Set(['rk_workspace']));
|
|
541
|
+
|
|
542
|
+
const subscribeFrames = MockWebSocket.instances
|
|
543
|
+
.flatMap((socket) => socket.sentFrames)
|
|
544
|
+
.map((payload) => JSON.parse(payload) as { type?: string; channels?: string[] })
|
|
545
|
+
.filter((payload) => payload.type === 'subscribe');
|
|
546
|
+
expect(subscribeFrames.length).toBeGreaterThanOrEqual(2);
|
|
547
|
+
expect(subscribeFrames.every((payload) => payload.channels?.join(',') === 'general')).toBe(true);
|
|
548
|
+
});
|
|
549
|
+
});
|