@zhin.js/client 2.0.6 → 2.1.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.
Files changed (39) hide show
  1. package/README.md +6 -0
  2. package/client/bootstrap/loadConsoleEntries.test.ts +28 -2
  3. package/client/bootstrap/loadConsoleEntries.ts +39 -1
  4. package/client/console-utils/remoteApi.ts +11 -1
  5. package/client/index.ts +16 -0
  6. package/client/mediaSrc.test.ts +34 -0
  7. package/client/mediaSrc.ts +16 -1
  8. package/client/persistence/idb-store.ts +13 -16
  9. package/client/websocket/hooks.ts +38 -23
  10. package/client/websocket/manager.test.ts +216 -0
  11. package/client/websocket/manager.ts +57 -28
  12. package/client/websocket/remote-settings.ts +13 -1
  13. package/dist/bootstrap/loadConsoleEntries.d.ts +9 -0
  14. package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -1
  15. package/dist/bootstrap/loadConsoleEntries.js +36 -1
  16. package/dist/bootstrap/loadConsoleEntries.js.map +1 -1
  17. package/dist/console-utils/remoteApi.d.ts.map +1 -1
  18. package/dist/console-utils/remoteApi.js +14 -1
  19. package/dist/console-utils/remoteApi.js.map +1 -1
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/mediaSrc.d.ts.map +1 -1
  25. package/dist/mediaSrc.js +17 -1
  26. package/dist/mediaSrc.js.map +1 -1
  27. package/dist/persistence/idb-store.d.ts.map +1 -1
  28. package/dist/persistence/idb-store.js +13 -17
  29. package/dist/persistence/idb-store.js.map +1 -1
  30. package/dist/websocket/hooks.d.ts.map +1 -1
  31. package/dist/websocket/hooks.js +38 -27
  32. package/dist/websocket/hooks.js.map +1 -1
  33. package/dist/websocket/manager.d.ts.map +1 -1
  34. package/dist/websocket/manager.js +47 -27
  35. package/dist/websocket/manager.js.map +1 -1
  36. package/dist/websocket/remote-settings.d.ts.map +1 -1
  37. package/dist/websocket/remote-settings.js +16 -1
  38. package/dist/websocket/remote-settings.js.map +1 -1
  39. package/package.json +3 -2
package/README.md CHANGED
@@ -23,9 +23,15 @@ Peer:`react >= 18`(`createPluginRegisterHostApi` 需要 React 引用)。
23
23
  | `app` | 路由与工具注册单例(`addRoute`、`addTool` 等) |
24
24
  | `configureConsole` / `getRuntimeEnv` | 运行时环境(development / production) |
25
25
  | `createRegistryStore` / `useRegistry` | 可选 registry store |
26
+ | `ENDPOINT_RPC` / `INBOX_RPC` / `SIDE_EVENT_RPC` / `SIDE_EVENT_PUSH` | `@zhin.js/console-protocol` 的规范 RPC 与推送名称 |
27
+ | `normalizeConsolePushType` / `normalizeConsolePushMessage` | 在 SDK 边界兼容旧 `endpoint:*` 推送并输出规范事件与 payload |
28
+ | `parseConsoleInboxEvent` | 一次完成 Inbox 推送名称、身份别名和 message/request/notice 分类 |
29
+ | `ConsoleEndpointSummary` / `EndpointManagementCapability` | Host 与 Remote Console 共享的 Endpoint wire 类型 |
26
30
 
27
31
  类型与 Entry 契约来自 `@zhin.js/contract`。
28
32
 
33
+ 业务 UI 应使用 SDK 导出的协议常量和 Endpoint 类型,不要硬编码旧 `endpoint:list`、`endpoint:sendMessage` 等名称,也不要复制 `EndpointInfo`。SDK 会把旧 Host 推送别名归一化为 `message.receive` / `request.receive` / `notice.receive`,并统一 `endpointId`、`channelId` 字段。
34
+
29
35
  ## 启动:加载插件 Console Entry
30
36
 
31
37
  Remote Console 壳层在登录后调用:
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import { createPluginRegisterHostApi, getRegisterFn } from "./loadConsoleEntries.js";
2
+ import {
3
+ createPluginRegisterHostApi,
4
+ getRegisterFn,
5
+ resolveEntryRegister,
6
+ } from "./loadConsoleEntries.js";
3
7
 
4
8
  describe("console entries bootstrap", () => {
5
9
  it("resolves named register export", () => {
@@ -16,6 +20,29 @@ describe("console entries bootstrap", () => {
16
20
  expect(getRegisterFn({ default: {} })).toBeNull();
17
21
  });
18
22
 
23
+ it("synthesizes register from default component + entry.route", () => {
24
+ const Page = () => null;
25
+ const register = resolveEntryRegister(
26
+ { default: Page, meta: { title: "Sandbox", icon: "Box" } },
27
+ { id: "sandbox", resolvedModule: "/assets/client/sandbox.js", route: "/p-sandbox" } as any,
28
+ );
29
+ expect(typeof register).toBe("function");
30
+ const addRoute = vi.fn();
31
+ const createElement = vi.fn((c: unknown) => ({ type: c }));
32
+ register!({
33
+ React: { createElement } as any,
34
+ addRoute,
35
+ addPage: addRoute,
36
+ addTool: vi.fn(),
37
+ });
38
+ expect(addRoute).toHaveBeenCalledWith(expect.objectContaining({
39
+ path: "/p-sandbox",
40
+ name: "Sandbox",
41
+ icon: "Box",
42
+ }));
43
+ expect(createElement).toHaveBeenCalledWith(Page);
44
+ });
45
+
19
46
  it("creates a host API with addPage aliased to addRoute", () => {
20
47
  const React = {} as any;
21
48
  const addRoute = vi.fn();
@@ -29,4 +56,3 @@ describe("console entries bootstrap", () => {
29
56
  expect(api.addTool({ id: "t", name: "T" })).toBe("tool-id");
30
57
  });
31
58
  });
32
-
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_CONSOLE_BASE_PATH, type ConsoleClientEntry, type ConsoleEntriesResponse, type ConsolePluginRegister, type PluginRegisterHostApi } from '@zhin.js/contract';
2
+ import type * as React from 'react';
2
3
 
3
4
  export type FetchConsoleEntriesOptions = {
4
5
  entriesUrl?: string;
@@ -91,6 +92,43 @@ export function getRegisterFn(mod: Record<string, unknown> | null | undefined):
91
92
  return null;
92
93
  }
93
94
 
95
+ /**
96
+ * Convention pages (ADR 0046) export a React component as `default` + optional `meta`,
97
+ * without a legacy `register(api)`. Synthesize register from entry.route / entry meta
98
+ * so Remote Console can still mount them.
99
+ */
100
+ export function resolveEntryRegister(
101
+ mod: Record<string, unknown> | null | undefined,
102
+ entry: ConsoleClientEntry & { readonly route?: string; readonly title?: string },
103
+ ): ConsolePluginRegister | null {
104
+ const named = getRegisterFn(mod);
105
+ if (named) return named;
106
+ if (!mod) return null;
107
+ const Component = mod["default"];
108
+ if (typeof Component !== "function" && !(Component && typeof Component === "object")) {
109
+ return null;
110
+ }
111
+ const path = typeof entry.route === "string" && entry.route
112
+ ? entry.route
113
+ : `/${entry.id}`;
114
+ const meta = mod["meta"] && typeof mod["meta"] === "object"
115
+ ? mod["meta"] as { title?: string; icon?: unknown; hideInNav?: boolean }
116
+ : undefined;
117
+ const name = (typeof meta?.title === "string" && meta.title)
118
+ || (typeof entry.title === "string" && entry.title)
119
+ || entry.meta?.name
120
+ || entry.id;
121
+ return (api) => {
122
+ api.addRoute({
123
+ path,
124
+ name,
125
+ element: api.React.createElement(Component as React.ComponentType),
126
+ ...(meta?.icon != null ? { icon: meta.icon } : {}),
127
+ meta: { hideInMenu: meta?.hideInNav === true },
128
+ });
129
+ };
130
+ }
131
+
94
132
  export async function registerConsolePluginsFromEntries(
95
133
  entries: ConsoleClientEntry[],
96
134
  hostApi: PluginRegisterHostApi,
@@ -103,7 +141,7 @@ export async function registerConsolePluginsFromEntries(
103
141
  try {
104
142
  const specifier = resolvePluginImportUrl(entry.resolvedModule, assetOrigin);
105
143
  const mod = (await import(/* @vite-ignore */ specifier)) as Record<string, unknown>;
106
- const register = getRegisterFn(mod);
144
+ const register = resolveEntryRegister(mod, entry);
107
145
  if (register) await register(hostApi);
108
146
  else throw new Error(`entry "${entry.id}" has no register export`);
109
147
  } catch (error) {
@@ -9,7 +9,17 @@ export function getApiBase(): string {
9
9
  }
10
10
 
11
11
  export function getToken(): string | null {
12
- return getRuntimeToken() ?? localStorage.getItem(TOKEN_KEY);
12
+ const runtime = getRuntimeToken();
13
+ if (runtime) return runtime;
14
+ if (typeof localStorage !== "undefined") {
15
+ const local = localStorage.getItem(TOKEN_KEY)?.trim();
16
+ if (local) return local;
17
+ }
18
+ if (typeof sessionStorage !== "undefined") {
19
+ const session = sessionStorage.getItem(TOKEN_KEY)?.trim();
20
+ if (session) return session;
21
+ }
22
+ return null;
13
23
  }
14
24
 
15
25
  export function resolveApiUrl(path: string): string {
package/client/index.ts CHANGED
@@ -1,5 +1,20 @@
1
1
  // Types
2
2
  export * from "./types";
3
+ export {
4
+ ENDPOINT_RPC,
5
+ ENDPOINT_MANAGEMENT_CAPABILITIES,
6
+ INBOX_RPC,
7
+ SIDE_EVENT_PUSH,
8
+ SIDE_EVENT_RPC,
9
+ normalizeConsolePushMessage,
10
+ normalizeConsolePushType,
11
+ parseConsoleInboxEvent,
12
+ type ConsoleInboxEvent,
13
+ type ConsoleInboxEventKind,
14
+ type ConsoleEndpointPhase,
15
+ type ConsoleEndpointSummary,
16
+ type EndpointManagementCapability,
17
+ } from "@zhin.js/console-protocol";
3
18
 
4
19
  // Media URL resolution
5
20
  export { resolveMediaSrc, pickMediaRawUrl, type MediaKind } from "./mediaSrc";
@@ -46,6 +61,7 @@ export {
46
61
  getRegisterFn,
47
62
  loadConsoleEntries,
48
63
  registerConsolePluginsFromEntries,
64
+ resolveEntryRegister,
49
65
  type CreatePluginRegisterHostApiOptions,
50
66
  type FetchConsoleEntriesOptions,
51
67
  type LoadConsoleEntriesOptions,
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { pickMediaRawUrl, resolveMediaSrc } from '../client/mediaSrc.js';
3
+
4
+ describe('pickMediaRawUrl', () => {
5
+ it('prefers canonical media.value / url / file', () => {
6
+ expect(pickMediaRawUrl({ media: { value: 'https://a/b.png' } })).toBe('https://a/b.png');
7
+ expect(pickMediaRawUrl({ url: 'https://a/b.png' })).toBe('https://a/b.png');
8
+ expect(pickMediaRawUrl({ file: '/tmp/a.png' })).toBe('/tmp/a.png');
9
+ });
10
+
11
+ it('reads agent outbound image segment (base64 + mime, no url)', () => {
12
+ // media-publisher 出站形态:{ type:'image', data:{ base64, mime, name } }
13
+ const raw = pickMediaRawUrl({ base64: 'aGVsbG8=', mime: 'image/png', name: 'system-status.png' });
14
+ expect(raw).toBe('data:image/png;base64,aGVsbG8=');
15
+ expect(resolveMediaSrc(raw, 'image')).toBe('data:image/png;base64,aGVsbG8=');
16
+ });
17
+
18
+ it('falls back to base64:// so resolveMediaSrc applies the kind default MIME', () => {
19
+ const raw = pickMediaRawUrl({ base64: 'aGVsbG8=' });
20
+ expect(raw).toBe('base64://aGVsbG8=');
21
+ expect(resolveMediaSrc(raw, 'image')).toBe('data:image/png;base64,aGVsbG8=');
22
+ expect(resolveMediaSrc(raw, 'audio')).toBe('data:audio/mpeg;base64,aGVsbG8=');
23
+ });
24
+
25
+ it('passes through base64 values that already carry a scheme', () => {
26
+ expect(pickMediaRawUrl({ base64: 'base64://aGVsbG8=' })).toBe('base64://aGVsbG8=');
27
+ expect(pickMediaRawUrl({ base64: 'data:image/png;base64,aGVsbG8=' })).toBe('data:image/png;base64,aGVsbG8=');
28
+ });
29
+
30
+ it('returns undefined when no media field exists', () => {
31
+ expect(pickMediaRawUrl(undefined)).toBeUndefined();
32
+ expect(pickMediaRawUrl({})).toBeUndefined();
33
+ });
34
+ });
@@ -52,5 +52,20 @@ export function pickMediaRawUrl(data: Record<string, unknown> | undefined): stri
52
52
  if (typeof value === 'string' && value.trim()) return value.trim()
53
53
  }
54
54
  const v = data.url ?? data.file ?? data.src ?? data.href
55
- return typeof v === 'string' && v.trim() ? v.trim() : undefined
55
+ if (typeof v === 'string' && v.trim()) return v.trim()
56
+ // agent 出站媒体段(media-publisher)直接携带纯 base64,无 url/file;
57
+ // 有 mime 时拼成 data: URL 透传,否则走 base64:// 让 resolveMediaSrc 按 kind 补默认 MIME
58
+ const b64 = data.base64
59
+ if (typeof b64 === 'string' && b64.trim()) {
60
+ const payload = b64.trim()
61
+ if (payload.startsWith('data:') || payload.startsWith('base64://')
62
+ || payload.startsWith('http://') || payload.startsWith('https://')) {
63
+ return payload
64
+ }
65
+ const mime = typeof data.mime === 'string' && data.mime.trim() ? data.mime.trim() : ''
66
+ return mime
67
+ ? `data:${mime};base64,${payload.replace(/\s/g, '')}`
68
+ : `base64://${payload}`
69
+ }
70
+ return undefined
56
71
  }
@@ -1,3 +1,5 @@
1
+ import { parseConsoleInboxEvent } from "@zhin.js/console-protocol";
2
+
1
3
  const DB_NAME = "zhin-console";
2
4
  const DB_VERSION = 2;
3
5
  const STORE_INBOX = "inbox";
@@ -63,20 +65,15 @@ export async function idbListInbox(
63
65
  }
64
66
 
65
67
  export async function applyConsoleEvent(event: { type: string; data?: unknown }): Promise<void> {
66
- const t = event.type;
67
- if (t === "message.receive" || t === "request.receive" || t === "notice.receive") {
68
- const data = event.data as Record<string, unknown> | undefined;
69
- if (!data) return;
70
- const adapter = String(data.$adapter ?? data.adapter ?? "");
71
- const endpoint_id = String(data.$endpoint ?? data.endpoint_id ?? data.endpointId ?? data.bot ?? "");
72
- const id = `${adapter}:${endpoint_id}:${t}:${Date.now()}`;
73
- await idbPutInbox({
74
- id,
75
- adapter,
76
- endpoint_id,
77
- kind: t === "message.receive" ? "message" : t === "request.receive" ? "request" : "notice",
78
- payload: data,
79
- updatedAt: Date.now(),
80
- });
81
- }
68
+ const parsed = parseConsoleInboxEvent(event);
69
+ if (!parsed) return;
70
+ const updatedAt = Date.now();
71
+ await idbPutInbox({
72
+ id: `${parsed.adapter}:${parsed.endpointId}:${parsed.type}:${updatedAt}`,
73
+ adapter: parsed.adapter,
74
+ endpoint_id: parsed.endpointId,
75
+ kind: parsed.kind,
76
+ payload: parsed.payload,
77
+ updatedAt,
78
+ });
82
79
  }
@@ -1,7 +1,25 @@
1
- import { useCallback, useEffect, useMemo, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { getWebSocketManager } from "./instance";
3
3
  import type { UseWebSocketOptions } from "./types";
4
4
 
5
+ /**
6
+ * 每次连接会话只对同一 key 自动加载一次:失败后不再零退避死循环
7
+ * (旧行为:加载失败 → 状态保持空 → effect 反复立即重试,打爆 /api/console/request)。
8
+ * 断连后重置;key 变化(如 pluginName 切换)视为新目标重新尝试;手动重试走各 hook 返回的 load 函数。
9
+ */
10
+ function useAutoLoadOnce(connected: boolean, key: unknown, ready: boolean, fire: () => void) {
11
+ const attemptedRef = useRef<unknown>(undefined);
12
+ useEffect(() => {
13
+ if (!connected) {
14
+ attemptedRef.current = undefined;
15
+ return;
16
+ }
17
+ if (ready || attemptedRef.current === key) return;
18
+ attemptedRef.current = key;
19
+ fire();
20
+ });
21
+ }
22
+
5
23
  export function useWebSocket(options: UseWebSocketOptions = {}) {
6
24
  const { autoConnect = true } = options;
7
25
  const wsManager = getWebSocketManager();
@@ -78,13 +96,12 @@ export function useConfig(pluginName: string, options?: { autoLoad?: boolean; au
78
96
  }
79
97
  }, [pluginName, wsManager]);
80
98
 
81
- useEffect(() => {
82
- if (connected && autoLoad && !config && !loading) getConfig().catch(() => {});
83
- }, [connected, autoLoad, config, loading, getConfig]);
84
-
85
- useEffect(() => {
86
- if (connected && autoLoadSchema && !schema) getSchema().catch(() => {});
87
- }, [connected, autoLoadSchema, schema, getSchema]);
99
+ useAutoLoadOnce(connected, `config:${pluginName}`, !autoLoad || config != null || loading, () => {
100
+ getConfig().catch(() => {});
101
+ });
102
+ useAutoLoadOnce(connected, `schema:${pluginName}`, !autoLoadSchema || schema != null, () => {
103
+ getSchema().catch(() => {});
104
+ });
88
105
 
89
106
  return useMemo(
90
107
  () => ({ config, schema, loading, error, connected, getConfig, setConfig, getSchema }),
@@ -136,9 +153,9 @@ export function useConfigYaml() {
136
153
  [wsManager],
137
154
  );
138
155
 
139
- useEffect(() => {
140
- if (connected && !yaml && !loading) load().catch(() => {});
141
- }, [connected, yaml, loading, load]);
156
+ useAutoLoadOnce(connected, "config-yaml", !!yaml || loading, () => {
157
+ load().catch(() => {});
158
+ });
142
159
 
143
160
  return useMemo(() => ({ yaml, pluginKeys, loading, error, load, save }), [yaml, pluginKeys, loading, error, load, save]);
144
161
  }
@@ -173,9 +190,9 @@ export function useFiles() {
173
190
  [wsManager],
174
191
  );
175
192
 
176
- useEffect(() => {
177
- if (connected && tree.length === 0 && !loading) loadTree().catch(() => {});
178
- }, [connected, tree.length, loading, loadTree]);
193
+ useAutoLoadOnce(connected, "file-tree", tree.length > 0 || loading, () => {
194
+ loadTree().catch(() => {});
195
+ });
179
196
 
180
197
  return useMemo(() => ({ tree, loading, error, loadTree, readFile, saveFile }), [tree, loading, error, loadTree, readFile, saveFile]);
181
198
  }
@@ -217,9 +234,9 @@ export function useEnvFiles() {
217
234
  [wsManager],
218
235
  );
219
236
 
220
- useEffect(() => {
221
- if (connected && files.length === 0 && !loading) listFiles().catch(() => {});
222
- }, [connected, files.length, loading, listFiles]);
237
+ useAutoLoadOnce(connected, "env-files", files.length > 0 || loading, () => {
238
+ listFiles().catch(() => {});
239
+ });
223
240
 
224
241
  return useMemo(
225
242
  () => ({ files, loading, error, listFiles, getFile, saveFile }),
@@ -275,12 +292,10 @@ export function useDatabase() {
275
292
  const kvDelete = useCallback(async (table: string, key: string) => wsManager.kvDelete(table, key), [wsManager]);
276
293
  const kvEntries = useCallback(async (table: string) => wsManager.kvGetEntries(table), [wsManager]);
277
294
 
278
- useEffect(() => {
279
- if (connected && !info && !loading) {
280
- loadInfo().catch(() => {});
281
- loadTables().catch(() => {});
282
- }
283
- }, [connected, info, loading, loadInfo, loadTables]);
295
+ useAutoLoadOnce(connected, "db-info", info != null || loading, () => {
296
+ loadInfo().catch(() => {});
297
+ loadTables().catch(() => {});
298
+ });
284
299
 
285
300
  return useMemo(
286
301
  () => ({ info, tables, loading, error, loadInfo, loadTables, dropTable, select, insert, update, remove, kvGet, kvSet, kvDelete, kvEntries }),
@@ -0,0 +1,216 @@
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 { WebSocketManager } from "./manager.js";
10
+ import { ConnectionState, type WebSocketMessage } 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
+ describe("WebSocketManager REST/SSE transport", () => {
58
+ beforeEach(() => {
59
+ vi.useFakeTimers();
60
+ mockApplyConsoleEvent.mockClear();
61
+ });
62
+
63
+ afterEach(() => {
64
+ vi.useRealTimers();
65
+ vi.unstubAllGlobals();
66
+ vi.restoreAllMocks();
67
+ });
68
+
69
+ it("connects SSE without a token (local Host with empty TokenRegistry)", async () => {
70
+ installBrowserGlobals();
71
+ const fetchMock = vi.fn(async () => sseResponse(200));
72
+ vi.stubGlobal("fetch", fetchMock);
73
+
74
+ const manager = new WebSocketManager({ reconnectInterval: 100, maxReconnectAttempts: 3 });
75
+ const states: boolean[] = [];
76
+ manager.onConnectionChange((c) => states.push(c));
77
+ manager.connect();
78
+
79
+ await vi.advanceTimersByTimeAsync(0);
80
+ await Promise.resolve();
81
+ await Promise.resolve();
82
+
83
+ expect(fetchMock).toHaveBeenCalled();
84
+ const init = fetchMock.mock.calls[0]![1] as RequestInit;
85
+ const headers = init.headers as Record<string, string>;
86
+ expect(headers.Accept).toBe("text/event-stream");
87
+ expect(headers.Authorization).toBeUndefined();
88
+ expect(manager.getState()).toBe(ConnectionState.CONNECTED);
89
+ expect(manager.isConnected()).toBe(true);
90
+ manager.disconnect();
91
+ });
92
+
93
+ it("sends Authorization when a token is stored", async () => {
94
+ installBrowserGlobals(mockStorage({ zhin_api_token: "secret" }));
95
+ const fetchMock = vi.fn(async () => sseResponse(200));
96
+ vi.stubGlobal("fetch", fetchMock);
97
+
98
+ const manager = new WebSocketManager();
99
+ manager.connect();
100
+ await vi.advanceTimersByTimeAsync(0);
101
+ await Promise.resolve();
102
+ await Promise.resolve();
103
+
104
+ const init = fetchMock.mock.calls[0]![1] as RequestInit;
105
+ expect((init.headers as Record<string, string>).Authorization).toBe("Bearer secret");
106
+ manager.disconnect();
107
+ });
108
+
109
+ it("reconnects after a transient SSE failure (no ERROR deadlock)", async () => {
110
+ installBrowserGlobals();
111
+ let calls = 0;
112
+ const fetchMock = vi.fn(async () => {
113
+ calls += 1;
114
+ if (calls === 1) return sseResponse(503, null);
115
+ return sseResponse(200);
116
+ });
117
+ vi.stubGlobal("fetch", fetchMock);
118
+
119
+ const manager = new WebSocketManager({
120
+ reconnectInterval: 100,
121
+ maxReconnectAttempts: 5,
122
+ });
123
+ manager.connect();
124
+
125
+ // First attempt fails → RECONNECTING
126
+ await vi.advanceTimersByTimeAsync(0);
127
+ await Promise.resolve();
128
+ await Promise.resolve();
129
+ expect(manager.getState()).toBe(ConnectionState.RECONNECTING);
130
+ expect(calls).toBe(1);
131
+
132
+ // scheduleReconnect delay = 100 * attempt(1) = 100ms
133
+ await vi.advanceTimersByTimeAsync(100);
134
+ await Promise.resolve();
135
+ await Promise.resolve();
136
+
137
+ expect(calls).toBeGreaterThanOrEqual(2);
138
+ expect(manager.getState()).toBe(ConnectionState.CONNECTED);
139
+ manager.disconnect();
140
+ });
141
+
142
+ it("sendRequest posts without Authorization when no token is stored", async () => {
143
+ installBrowserGlobals();
144
+ const fetchMock = vi.fn(async () => ({
145
+ ok: true,
146
+ status: 200,
147
+ json: async () => ({ success: true, data: { yaml: "http:\n port: 1\n" } }),
148
+ } as Response));
149
+ vi.stubGlobal("fetch", fetchMock);
150
+
151
+ const manager = new WebSocketManager();
152
+ const data = await manager.getConfigYaml();
153
+ expect(data.yaml).toContain("port");
154
+ const init = fetchMock.mock.calls[0]![1] as RequestInit;
155
+ expect((init.headers as Record<string, string>).Authorization).toBeUndefined();
156
+ expect(init.method).toBe("POST");
157
+ });
158
+
159
+ it("sendRequest includes Bearer token when present", async () => {
160
+ installBrowserGlobals(mockStorage({ zhin_api_token: "t" }));
161
+ const fetchMock = vi.fn(async () => ({
162
+ ok: true,
163
+ status: 200,
164
+ json: async () => ({ success: true, data: { ok: true } }),
165
+ } as Response));
166
+ vi.stubGlobal("fetch", fetchMock);
167
+
168
+ const manager = new WebSocketManager();
169
+ await manager.setConfig("sandbox", { endpoints: [] });
170
+ const init = fetchMock.mock.calls[0]![1] as RequestInit;
171
+ expect((init.headers as Record<string, string>).Authorization).toBe("Bearer t");
172
+ });
173
+
174
+ it("normalizes legacy endpoint push names and payload aliases before callbacks", async () => {
175
+ installBrowserGlobals();
176
+ const received: WebSocketMessage[] = [];
177
+ const manager = new WebSocketManager({}, {
178
+ onMessage: (message) => received.push(message),
179
+ });
180
+
181
+ (manager as unknown as { handleMessage(event: MessageEvent): void }).handleMessage({
182
+ data: '{"type":"endpoint:message","data":{"$adapter":"sandbox","endpoint":"bot"}}',
183
+ } as MessageEvent);
184
+ await Promise.resolve();
185
+
186
+ expect(received).toEqual([expect.objectContaining({
187
+ type: "message.receive",
188
+ data: expect.objectContaining({ adapter: "sandbox", endpointId: "bot" }),
189
+ })]);
190
+ expect(mockApplyConsoleEvent).toHaveBeenCalledWith(expect.objectContaining({
191
+ type: "message.receive",
192
+ }));
193
+ });
194
+
195
+ it("stops reconnecting after maxReconnectAttempts", async () => {
196
+ installBrowserGlobals();
197
+ const fetchMock = vi.fn(async () => sseResponse(500, null));
198
+ vi.stubGlobal("fetch", fetchMock);
199
+
200
+ const manager = new WebSocketManager({
201
+ reconnectInterval: 10,
202
+ maxReconnectAttempts: 2,
203
+ });
204
+ manager.connect();
205
+
206
+ for (let i = 0; i < 6; i++) {
207
+ await vi.advanceTimersByTimeAsync(50);
208
+ await Promise.resolve();
209
+ await Promise.resolve();
210
+ }
211
+
212
+ expect(manager.getState()).toBe(ConnectionState.ERROR);
213
+ expect(fetchMock.mock.calls.length).toBeLessThanOrEqual(3);
214
+ manager.disconnect();
215
+ });
216
+ });