@zhin.js/client 1.1.4 → 1.1.6

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 (147) hide show
  1. package/README.md +94 -432
  2. package/client/agent-stream.ts +121 -0
  3. package/client/app.test.ts +37 -0
  4. package/client/app.ts +130 -138
  5. package/client/bootstrap/loadConsoleEntries.test.ts +23 -12
  6. package/client/bootstrap/loadConsoleEntries.ts +40 -27
  7. package/client/console-client-context.ts +24 -0
  8. package/client/console-client.test.ts +46 -0
  9. package/client/console-client.ts +70 -0
  10. package/client/console-events.ts +38 -0
  11. package/client/console-utils/remoteApi.test.ts +91 -0
  12. package/client/console-utils/remoteApi.ts +47 -9
  13. package/client/index.ts +92 -28
  14. package/client/mediaSrc.test.ts +49 -0
  15. package/client/mediaSrc.ts +84 -11
  16. package/client/message-content/MarkdownContent.tsx +128 -0
  17. package/client/message-content/markdown.test.ts +48 -0
  18. package/client/message-content/markdown.ts +247 -0
  19. package/client/persistence/idb-store.test.ts +121 -0
  20. package/client/persistence/idb-store.ts +40 -21
  21. package/client/segments.test.ts +16 -0
  22. package/client/segments.ts +39 -0
  23. package/client/transport/console-transport.test.ts +394 -0
  24. package/client/transport/console-transport.ts +676 -0
  25. package/client/transport/hooks.test.ts +21 -0
  26. package/client/transport/hooks.ts +535 -0
  27. package/client/transport/index.ts +23 -0
  28. package/client/{websocket → transport}/types.ts +16 -17
  29. package/client/types.ts +5 -8
  30. package/client/workroom/WorkroomRunsPage.tsx +355 -0
  31. package/client/workroom/registerWorkroomConsole.test.ts +25 -0
  32. package/client/workroom/registerWorkroomConsole.ts +17 -0
  33. package/dist/agent-stream.d.ts +17 -0
  34. package/dist/agent-stream.d.ts.map +1 -0
  35. package/dist/agent-stream.js +73 -0
  36. package/dist/agent-stream.js.map +1 -0
  37. package/dist/app.d.ts +17 -52
  38. package/dist/app.d.ts.map +1 -1
  39. package/dist/app.js +110 -95
  40. package/dist/app.js.map +1 -1
  41. package/dist/bootstrap/loadConsoleEntries.d.ts +10 -7
  42. package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -1
  43. package/dist/bootstrap/loadConsoleEntries.js +37 -11
  44. package/dist/bootstrap/loadConsoleEntries.js.map +1 -1
  45. package/dist/console-client-context.d.ts +9 -0
  46. package/dist/console-client-context.d.ts.map +1 -0
  47. package/dist/console-client-context.js +13 -0
  48. package/dist/console-client-context.js.map +1 -0
  49. package/dist/console-client.d.ts +25 -0
  50. package/dist/console-client.d.ts.map +1 -0
  51. package/dist/console-client.js +45 -0
  52. package/dist/console-client.js.map +1 -0
  53. package/dist/console-events.d.ts +8 -0
  54. package/dist/console-events.d.ts.map +1 -0
  55. package/dist/console-events.js +26 -0
  56. package/dist/console-events.js.map +1 -0
  57. package/dist/console-utils/remoteApi.d.ts +2 -1
  58. package/dist/console-utils/remoteApi.d.ts.map +1 -1
  59. package/dist/console-utils/remoteApi.js +46 -8
  60. package/dist/console-utils/remoteApi.js.map +1 -1
  61. package/dist/index.d.ts +14 -7
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +18 -8
  64. package/dist/index.js.map +1 -1
  65. package/dist/mediaSrc.d.ts +1 -1
  66. package/dist/mediaSrc.d.ts.map +1 -1
  67. package/dist/mediaSrc.js +91 -11
  68. package/dist/mediaSrc.js.map +1 -1
  69. package/dist/message-content/MarkdownContent.d.ts +11 -0
  70. package/dist/message-content/MarkdownContent.d.ts.map +1 -0
  71. package/dist/message-content/MarkdownContent.js +68 -0
  72. package/dist/message-content/MarkdownContent.js.map +1 -0
  73. package/dist/message-content/markdown.d.ts +47 -0
  74. package/dist/message-content/markdown.d.ts.map +1 -0
  75. package/dist/message-content/markdown.js +219 -0
  76. package/dist/message-content/markdown.js.map +1 -0
  77. package/dist/persistence/idb-store.d.ts +5 -2
  78. package/dist/persistence/idb-store.d.ts.map +1 -1
  79. package/dist/persistence/idb-store.js +29 -20
  80. package/dist/persistence/idb-store.js.map +1 -1
  81. package/dist/segments.d.ts +9 -0
  82. package/dist/segments.d.ts.map +1 -0
  83. package/dist/segments.js +35 -0
  84. package/dist/segments.js.map +1 -0
  85. package/dist/transport/console-transport.d.ts +217 -0
  86. package/dist/transport/console-transport.d.ts.map +1 -0
  87. package/dist/transport/console-transport.js +563 -0
  88. package/dist/transport/console-transport.js.map +1 -0
  89. package/dist/transport/hooks.d.ts +253 -0
  90. package/dist/transport/hooks.d.ts.map +1 -0
  91. package/dist/transport/hooks.js +483 -0
  92. package/dist/transport/hooks.js.map +1 -0
  93. package/dist/transport/index.d.ts +4 -0
  94. package/dist/transport/index.d.ts.map +1 -0
  95. package/dist/transport/index.js +4 -0
  96. package/dist/transport/index.js.map +1 -0
  97. package/dist/{websocket → transport}/types.d.ts +14 -13
  98. package/dist/transport/types.d.ts.map +1 -0
  99. package/dist/{websocket → transport}/types.js +4 -9
  100. package/dist/transport/types.js.map +1 -0
  101. package/dist/types.d.ts +4 -4
  102. package/dist/types.d.ts.map +1 -1
  103. package/dist/workroom/WorkroomRunsPage.d.ts +2 -0
  104. package/dist/workroom/WorkroomRunsPage.d.ts.map +1 -0
  105. package/dist/workroom/WorkroomRunsPage.js +111 -0
  106. package/dist/workroom/WorkroomRunsPage.js.map +1 -0
  107. package/dist/workroom/registerWorkroomConsole.d.ts +4 -0
  108. package/dist/workroom/registerWorkroomConsole.d.ts.map +1 -0
  109. package/dist/workroom/registerWorkroomConsole.js +16 -0
  110. package/dist/workroom/registerWorkroomConsole.js.map +1 -0
  111. package/package.json +18 -8
  112. package/client/runtime/index.ts +0 -12
  113. package/client/websocket/hooks.ts +0 -289
  114. package/client/websocket/index.ts +0 -6
  115. package/client/websocket/instance.ts +0 -24
  116. package/client/websocket/manager.ts +0 -426
  117. package/client/websocket/remote-settings.ts +0 -25
  118. package/dist/bootstrap/loadConsoleEntries.test.d.ts +0 -2
  119. package/dist/bootstrap/loadConsoleEntries.test.d.ts.map +0 -1
  120. package/dist/bootstrap/loadConsoleEntries.test.js +0 -27
  121. package/dist/bootstrap/loadConsoleEntries.test.js.map +0 -1
  122. package/dist/runtime/index.d.ts +0 -6
  123. package/dist/runtime/index.d.ts.map +0 -1
  124. package/dist/runtime/index.js +0 -10
  125. package/dist/runtime/index.js.map +0 -1
  126. package/dist/websocket/hooks.d.ts +0 -105
  127. package/dist/websocket/hooks.d.ts.map +0 -1
  128. package/dist/websocket/hooks.js +0 -247
  129. package/dist/websocket/hooks.js.map +0 -1
  130. package/dist/websocket/index.d.ts +0 -7
  131. package/dist/websocket/index.d.ts.map +0 -1
  132. package/dist/websocket/index.js +0 -7
  133. package/dist/websocket/index.js.map +0 -1
  134. package/dist/websocket/instance.d.ts +0 -5
  135. package/dist/websocket/instance.d.ts.map +0 -1
  136. package/dist/websocket/instance.js +0 -21
  137. package/dist/websocket/instance.js.map +0 -1
  138. package/dist/websocket/manager.d.ts +0 -113
  139. package/dist/websocket/manager.d.ts.map +0 -1
  140. package/dist/websocket/manager.js +0 -403
  141. package/dist/websocket/manager.js.map +0 -1
  142. package/dist/websocket/remote-settings.d.ts +0 -5
  143. package/dist/websocket/remote-settings.d.ts.map +0 -1
  144. package/dist/websocket/remote-settings.js +0 -23
  145. package/dist/websocket/remote-settings.js.map +0 -1
  146. package/dist/websocket/types.d.ts.map +0 -1
  147. package/dist/websocket/types.js.map +0 -1
@@ -0,0 +1,394 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mockApplyConsoleEvent = vi.fn(async () => undefined);
4
+
5
+ vi.mock("../persistence/idb-store.js", () => ({
6
+ applyConsoleEvent: (...args: unknown[]) => mockApplyConsoleEvent(...args),
7
+ }));
8
+
9
+ import { ConsoleTransport } from "./console-transport.js";
10
+ import { ConnectionState } from "./types.js";
11
+
12
+ function mockStorage(map: Record<string, string> = {}): Storage {
13
+ return {
14
+ getItem: (key: string) => map[key] ?? null,
15
+ setItem: (key: string, value: string) => {
16
+ map[key] = value;
17
+ },
18
+ removeItem: (key: string) => {
19
+ delete map[key];
20
+ },
21
+ clear: () => {
22
+ for (const key of Object.keys(map)) delete map[key];
23
+ },
24
+ key: () => null,
25
+ length: 0,
26
+ };
27
+ }
28
+
29
+ function installBrowserGlobals(storage: Storage = mockStorage()) {
30
+ const win = {
31
+ location: { origin: "http://localhost:5173", protocol: "http:", host: "localhost:5173" },
32
+ __ZHIN_API_TOKEN: undefined as string | undefined,
33
+ dispatchEvent: vi.fn(),
34
+ };
35
+ vi.stubGlobal("window", win);
36
+ vi.stubGlobal("localStorage", storage);
37
+ vi.stubGlobal("sessionStorage", mockStorage());
38
+ return { win, storage };
39
+ }
40
+
41
+ function sseResponse(status = 200, body?: ReadableStream<Uint8Array> | null) {
42
+ return {
43
+ ok: status >= 200 && status < 300,
44
+ status,
45
+ body: body === undefined
46
+ ? new ReadableStream<Uint8Array>({
47
+ start(controller) {
48
+ // Keep the stream open until abort; tests control lifecycle via disconnect().
49
+ void controller;
50
+ },
51
+ })
52
+ : body,
53
+ json: async () => ({}),
54
+ } as Response;
55
+ }
56
+
57
+ function eventHistoryResponse(items: unknown[] = [], runtimeId = 'runtime-test', latestEventId?: number) {
58
+ const inferredLatest = (items.at(-1) as { eventId?: number } | undefined)?.eventId ?? 0;
59
+ return {
60
+ ok: true,
61
+ status: 200,
62
+ json: async () => ({
63
+ success: true,
64
+ data: {
65
+ runtimeId,
66
+ items,
67
+ oldestAvailableEventId: items.length ? 1 : null,
68
+ latestEventId: latestEventId ?? inferredLatest,
69
+ nextAfter: inferredLatest,
70
+ hasMore: false,
71
+ gap: false,
72
+ },
73
+ }),
74
+ } as Response;
75
+ }
76
+
77
+ function mockConsoleTransport(sse: () => Response | Promise<Response>) {
78
+ return vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
79
+ return String(input).includes('/api/events/history') ? eventHistoryResponse() : await sse();
80
+ });
81
+ }
82
+
83
+ describe("ConsoleTransport REST/SSE transport", () => {
84
+ beforeEach(() => {
85
+ vi.useFakeTimers();
86
+ mockApplyConsoleEvent.mockClear();
87
+ });
88
+
89
+ afterEach(() => {
90
+ vi.useRealTimers();
91
+ vi.unstubAllGlobals();
92
+ vi.restoreAllMocks();
93
+ });
94
+
95
+ it("connects SSE without a token (local Host with empty TokenRegistry)", async () => {
96
+ installBrowserGlobals();
97
+ const fetchMock = mockConsoleTransport(() => sseResponse(200));
98
+ vi.stubGlobal("fetch", fetchMock);
99
+
100
+ const manager = new ConsoleTransport({ reconnectInterval: 100, maxReconnectAttempts: 3 });
101
+ const states: boolean[] = [];
102
+ manager.onConnectionChange((c) => states.push(c));
103
+ manager.connect();
104
+
105
+ await vi.advanceTimersByTimeAsync(0);
106
+ await Promise.resolve();
107
+ await Promise.resolve();
108
+
109
+ expect(fetchMock).toHaveBeenCalled();
110
+ const sseCall = fetchMock.mock.calls.find(([input]) => !String(input).includes('/history'))!;
111
+ const init = sseCall[1] as RequestInit;
112
+ const headers = init.headers as Record<string, string>;
113
+ expect(headers.Accept).toBe("text/event-stream");
114
+ expect(headers.Authorization).toBeUndefined();
115
+ expect(manager.getState()).toBe(ConnectionState.CONNECTED);
116
+ expect(manager.isConnected()).toBe(true);
117
+ manager.disconnect();
118
+ });
119
+
120
+ it("sends Authorization when a token is stored", async () => {
121
+ installBrowserGlobals(mockStorage({ zhin_api_token: "secret" }));
122
+ const fetchMock = mockConsoleTransport(() => sseResponse(200));
123
+ vi.stubGlobal("fetch", fetchMock);
124
+
125
+ const manager = new ConsoleTransport();
126
+ manager.connect();
127
+ await vi.advanceTimersByTimeAsync(0);
128
+ await Promise.resolve();
129
+ await Promise.resolve();
130
+
131
+ const sseCall = fetchMock.mock.calls.find(([input]) => !String(input).includes('/history'))!;
132
+ const init = sseCall[1] as RequestInit;
133
+ expect((init.headers as Record<string, string>).Authorization).toBe("Bearer secret");
134
+ manager.disconnect();
135
+ });
136
+
137
+ it("reconnects after a transient SSE failure (no ERROR deadlock)", async () => {
138
+ installBrowserGlobals();
139
+ let sseCalls = 0;
140
+ const fetchMock = mockConsoleTransport(() => {
141
+ sseCalls += 1;
142
+ if (sseCalls === 1) return sseResponse(503, null);
143
+ return sseResponse(200);
144
+ });
145
+ vi.stubGlobal("fetch", fetchMock);
146
+
147
+ const manager = new ConsoleTransport({
148
+ reconnectInterval: 100,
149
+ maxReconnectAttempts: 5,
150
+ });
151
+ manager.connect();
152
+
153
+ // First attempt fails → RECONNECTING
154
+ await vi.advanceTimersByTimeAsync(0);
155
+ await Promise.resolve();
156
+ await Promise.resolve();
157
+ expect(manager.getState()).toBe(ConnectionState.RECONNECTING);
158
+ expect(sseCalls).toBe(1);
159
+
160
+ // scheduleReconnect delay = 100 * attempt(1) = 100ms
161
+ await vi.advanceTimersByTimeAsync(100);
162
+ await Promise.resolve();
163
+ await Promise.resolve();
164
+
165
+ expect(sseCalls).toBeGreaterThanOrEqual(2);
166
+ expect(manager.getState()).toBe(ConnectionState.CONNECTED);
167
+ manager.disconnect();
168
+ });
169
+
170
+ it("sendRequest posts without Authorization when no token is stored", async () => {
171
+ installBrowserGlobals();
172
+ const fetchMock = vi.fn(async () => ({
173
+ ok: true,
174
+ status: 200,
175
+ json: async () => ({
176
+ success: true,
177
+ data: {
178
+ source: "http:\n port: 1\n",
179
+ format: 'yaml',
180
+ revision: 'a'.repeat(64),
181
+ configKeys: [],
182
+ },
183
+ }),
184
+ } as Response));
185
+ vi.stubGlobal("fetch", fetchMock);
186
+
187
+ const manager = new ConsoleTransport();
188
+ const data = await manager.getConfigSource();
189
+ expect(data.source).toContain("port");
190
+ const init = fetchMock.mock.calls[0]![1] as RequestInit;
191
+ expect((init.headers as Record<string, string>).Authorization).toBeUndefined();
192
+ expect(init.method).toBe("POST");
193
+ });
194
+
195
+ it("sendRequest includes Bearer token when present", async () => {
196
+ installBrowserGlobals(mockStorage({ zhin_api_token: "t" }));
197
+ const fetchMock = vi.fn(async () => ({
198
+ ok: true,
199
+ status: 200,
200
+ json: async () => ({ success: true, data: { ok: true } }),
201
+ } as Response));
202
+ vi.stubGlobal("fetch", fetchMock);
203
+
204
+ const manager = new ConsoleTransport();
205
+ await manager.setConfig("sandbox", { endpoints: [] });
206
+ const init = fetchMock.mock.calls[0]![1] as RequestInit;
207
+ expect((init.headers as Record<string, string>).Authorization).toBe("Bearer t");
208
+ });
209
+
210
+ it('exposes marketplace discovery and lifecycle methods for Console pages', async () => {
211
+ installBrowserGlobals(mockStorage({ zhin_api_token: 'token' }));
212
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => ({
213
+ ok: true,
214
+ status: 200,
215
+ json: async () => ({
216
+ success: true,
217
+ data: String(input).includes('/marketplace/search') ? { items: [] } : { ok: true },
218
+ }),
219
+ headers: init?.headers,
220
+ } as Response));
221
+ const manager = new ConsoleTransport({ fetch: fetchMock });
222
+
223
+ await manager.searchMarketplace({ keyword: 'telegram', official: true });
224
+ await manager.getMarketplacePlugin('@zhin.js/adapter-telegram');
225
+ await manager.getPluginUpdates();
226
+ await manager.setPluginEnabled('telegram', false);
227
+
228
+ expect(String(fetchMock.mock.calls[0]![0])).toContain('/pub/marketplace/search?q=telegram&official=true');
229
+ expect(String(fetchMock.mock.calls[1]![0])).toContain('%40zhin.js%2Fadapter-telegram');
230
+ expect(String(fetchMock.mock.calls[2]![0])).toContain('/api/marketplace/updates');
231
+ const lifecycleBody = JSON.parse(String((fetchMock.mock.calls[3]![1] as RequestInit).body));
232
+ expect(lifecycleBody).toMatchObject({
233
+ type: 'plugin:set-enabled',
234
+ instanceKey: 'telegram',
235
+ enabled: false,
236
+ });
237
+ });
238
+
239
+ it("stops reconnecting after maxReconnectAttempts", async () => {
240
+ installBrowserGlobals();
241
+ const fetchMock = mockConsoleTransport(() => sseResponse(500, null));
242
+ vi.stubGlobal("fetch", fetchMock);
243
+
244
+ const manager = new ConsoleTransport({
245
+ reconnectInterval: 10,
246
+ maxReconnectAttempts: 2,
247
+ });
248
+ manager.connect();
249
+
250
+ for (let i = 0; i < 6; i++) {
251
+ await vi.advanceTimersByTimeAsync(50);
252
+ await Promise.resolve();
253
+ await Promise.resolve();
254
+ }
255
+
256
+ expect(manager.getState()).toBe(ConnectionState.ERROR);
257
+ const sseCalls = fetchMock.mock.calls.filter(([input]) => !String(input).includes('/history'));
258
+ expect(sseCalls.length).toBeLessThanOrEqual(3);
259
+ manager.disconnect();
260
+ });
261
+
262
+ it('recovers typed history before live SSE and preserves event metadata', async () => {
263
+ installBrowserGlobals();
264
+ const encoder = new TextEncoder();
265
+ const stream = new ReadableStream<Uint8Array>({
266
+ start(controller) {
267
+ controller.enqueue(encoder.encode([
268
+ 'id: 2',
269
+ 'event: message.receive',
270
+ 'runtime: runtime-a',
271
+ 'timestamp: 200',
272
+ 'data: {"adapter":"sandbox","endpointKey":"bot","content":"live"}',
273
+ '',
274
+ '',
275
+ ].join('\n')));
276
+ },
277
+ });
278
+ const historyEvent = {
279
+ runtimeId: 'runtime-a', eventId: 1, type: 'message.receive', timestamp: 100,
280
+ data: { adapter: 'sandbox', endpointKey: 'bot', content: 'history' },
281
+ };
282
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => (
283
+ String(input).includes('/history')
284
+ ? eventHistoryResponse([historyEvent], 'runtime-a')
285
+ : sseResponse(200, stream)
286
+ ));
287
+ vi.stubGlobal('fetch', fetchMock);
288
+ const received: Array<{ eventId: number; delivery?: string }> = [];
289
+ const manager = new ConsoleTransport();
290
+ manager.onConsoleEvent('message.receive', (event) => received.push({ eventId: event.eventId, delivery: event.delivery }));
291
+ manager.connect();
292
+ await vi.advanceTimersByTimeAsync(0);
293
+ await Promise.resolve();
294
+ await Promise.resolve();
295
+
296
+ expect(received).toEqual([
297
+ { eventId: 1, delivery: 'history' },
298
+ { eventId: 2, delivery: 'live' },
299
+ ]);
300
+ expect(mockApplyConsoleEvent).toHaveBeenCalledTimes(2);
301
+ manager.disconnect();
302
+ });
303
+
304
+ it('keeps an in-memory cursor when localStorage is unavailable', async () => {
305
+ const storage = mockStorage();
306
+ storage.setItem = () => { throw new Error('storage disabled'); };
307
+ installBrowserGlobals(storage);
308
+ const historyEvent = {
309
+ runtimeId: 'runtime-a', eventId: 1, type: 'message.receive', timestamp: 100,
310
+ data: { adapter: 'sandbox', endpointKey: 'bot', content: 'history' },
311
+ };
312
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
313
+ const after = new URL(String(input)).searchParams.get('after');
314
+ return after === '1'
315
+ ? eventHistoryResponse([], 'runtime-a', 1)
316
+ : eventHistoryResponse([historyEvent], 'runtime-a', 1);
317
+ });
318
+ vi.stubGlobal('fetch', fetchMock);
319
+ const received: number[] = [];
320
+ const manager = new ConsoleTransport();
321
+ manager.onConsoleEvent('message.receive', (event) => received.push(event.eventId));
322
+ const recover = manager as unknown as { recoverEventHistory(signal: AbortSignal): Promise<void> };
323
+ await recover.recoverEventHistory(new AbortController().signal);
324
+ await recover.recoverEventHistory(new AbortController().signal);
325
+
326
+ expect(received).toEqual([1]);
327
+ expect(fetchMock.mock.calls[1]?.[0]).toContain('after=1');
328
+ });
329
+
330
+ it('exposes a typed recovery gap instead of silently treating it as complete', async () => {
331
+ installBrowserGlobals();
332
+ const response = eventHistoryResponse([], 'runtime-new', 9);
333
+ const originalJson = response.json;
334
+ response.json = async () => {
335
+ const body = await originalJson() as { data: Record<string, unknown> };
336
+ return { ...body, data: { ...body.data, gap: true, oldestAvailableEventId: 8 } };
337
+ };
338
+ vi.stubGlobal('fetch', vi.fn(async () => response));
339
+ const manager = new ConsoleTransport();
340
+ const gaps: unknown[] = [];
341
+ manager.onConsoleEventRecoveryGap((gap) => gaps.push(gap));
342
+ const recover = manager as unknown as { recoverEventHistory(signal: AbortSignal): Promise<void> };
343
+ await recover.recoverEventHistory(new AbortController().signal);
344
+
345
+ expect(gaps).toEqual([expect.objectContaining({
346
+ page: expect.objectContaining({ runtimeId: 'runtime-new', gap: true }),
347
+ })]);
348
+ });
349
+
350
+ it('does not persist a cursor past an inbox event that failed durable storage', async () => {
351
+ const stored: Record<string, string> = {};
352
+ installBrowserGlobals(mockStorage(stored));
353
+ mockApplyConsoleEvent.mockRejectedValueOnce(new Error('IndexedDB unavailable'));
354
+ const historyEvent = {
355
+ runtimeId: 'runtime-a', eventId: 1, type: 'message.receive', timestamp: 100,
356
+ data: { adapter: 'sandbox', endpointKey: 'bot', content: 'history' },
357
+ };
358
+ vi.stubGlobal('fetch', vi.fn(async () => eventHistoryResponse([historyEvent], 'runtime-a', 1)));
359
+ const manager = new ConsoleTransport();
360
+ const recover = manager as unknown as { recoverEventHistory(signal: AbortSignal): Promise<void> };
361
+ await recover.recoverEventHistory(new AbortController().signal);
362
+
363
+ expect(Object.keys(stored).filter((key) => key.includes('event-cursor'))).toEqual([]);
364
+ });
365
+
366
+ it('makes disposal terminal and idempotent', () => {
367
+ const transport = new ConsoleTransport();
368
+ transport.dispose();
369
+ transport.dispose();
370
+
371
+ expect(transport.getState()).toBe(ConnectionState.DISCONNECTED);
372
+ expect(() => transport.connect()).toThrow('ConsoleTransport has been disposed');
373
+ expect(() => transport.onConnectionChange(() => {}))
374
+ .toThrow('ConsoleTransport has been disposed');
375
+ });
376
+
377
+ it('rejects a stale connection result after disposal', async () => {
378
+ installBrowserGlobals();
379
+ let resolveHistory!: (response: Response) => void;
380
+ const fetchMock = vi.fn(() => new Promise<Response>((resolve) => {
381
+ resolveHistory = resolve;
382
+ }));
383
+ const transport = new ConsoleTransport({ fetch: fetchMock });
384
+
385
+ transport.connect();
386
+ transport.dispose();
387
+ resolveHistory(eventHistoryResponse());
388
+ await Promise.resolve();
389
+ await Promise.resolve();
390
+
391
+ expect(transport.getState()).toBe(ConnectionState.DISCONNECTED);
392
+ expect(fetchMock).toHaveBeenCalledOnce();
393
+ });
394
+ });