



























@@ -0,0 +1,195 @@
1+import { afterEach, describe, expect, it, vi } from "vitest";
2+import { initNativeBridge, isWebView2, sendToNative } from "./app-native-bridge.ts";
3+import {
4+handleChatDraftChange as applyDraftChange,
5+navigateChatInputHistory,
6+type ChatInputHistoryState,
7+} from "./chat/input-history.ts";
8+9+type FakeBridge = {
10+postMessage: ReturnType<typeof vi.fn>;
11+addEventListener: ReturnType<typeof vi.fn>;
12+removeEventListener: ReturnType<typeof vi.fn>;
13+listeners: ((e: MessageEvent) => void)[];
14+posted: unknown[];
15+};
16+17+function makeBridge(): FakeBridge {
18+const listeners: ((e: MessageEvent) => void)[] = [];
19+const posted: unknown[] = [];
20+const bridge: FakeBridge = {
21+ posted,
22+ listeners,
23+postMessage: vi.fn((msg: unknown) => posted.push(msg)),
24+addEventListener: vi.fn((_type: string, fn: (e: MessageEvent) => void) => listeners.push(fn)),
25+removeEventListener: vi.fn((_type: string, fn: (e: MessageEvent) => void) => {
26+const i = listeners.indexOf(fn);
27+if (i !== -1) {
28+listeners.splice(i, 1);
29+}
30+}),
31+};
32+vi.stubGlobal("chrome", { webview: bridge });
33+return bridge;
34+}
35+36+function makeHost() {
37+return { handleChatDraftChange: vi.fn() };
38+}
39+40+function dispatch(bridge: FakeBridge, data: unknown) {
41+const event = { data } as MessageEvent;
42+for (const fn of bridge.listeners) {
43+fn(event);
44+}
45+}
46+47+afterEach(() => {
48+vi.unstubAllGlobals();
49+});
50+51+describe("isWebView2", () => {
52+it("returns false when window.chrome.webview is absent", () => {
53+expect(isWebView2()).toBe(false);
54+});
55+56+it("returns true when window.chrome.webview is present", () => {
57+makeBridge();
58+expect(isWebView2()).toBe(true);
59+});
60+});
61+62+describe("sendToNative", () => {
63+it("posts the message to the webview", () => {
64+const bridge = makeBridge();
65+sendToNative({ type: "ready" });
66+expect(bridge.posted).toEqual([{ type: "ready" }]);
67+});
68+69+it("does nothing outside WebView2", () => {
70+expect(() => sendToNative({ type: "ready" })).not.toThrow();
71+});
72+});
73+74+describe("initNativeBridge", () => {
75+it("registers listener before sending ready handshake", () => {
76+const callOrder: string[] = [];
77+const webview = {
78+postMessage: vi.fn(() => callOrder.push("post")),
79+addEventListener: vi.fn(() => callOrder.push("listen")),
80+removeEventListener: vi.fn(),
81+};
82+vi.stubGlobal("chrome", { webview });
83+initNativeBridge(makeHost());
84+expect(callOrder).toEqual(["listen", "post"]);
85+});
86+87+it("sends ready handshake on init", () => {
88+const bridge = makeBridge();
89+initNativeBridge(makeHost());
90+expect(bridge.posted).toEqual([{ type: "ready" }]);
91+});
92+93+it("is a no-op outside WebView2", () => {
94+const host = makeHost();
95+const cleanup = initNativeBridge(host);
96+expect(host.handleChatDraftChange).not.toHaveBeenCalled();
97+expect(() => cleanup()).not.toThrow();
98+});
99+100+it("calls handleChatDraftChange for a valid draft-text message", () => {
101+const bridge = makeBridge();
102+const host = makeHost();
103+initNativeBridge(host);
104+dispatch(bridge, { type: "draft-text", payload: { text: "hello from native" } });
105+expect(host.handleChatDraftChange).toHaveBeenCalledWith("hello from native");
106+});
107+108+it("ignores draft-text with missing payload", () => {
109+const bridge = makeBridge();
110+const host = makeHost();
111+initNativeBridge(host);
112+dispatch(bridge, { type: "draft-text" });
113+expect(host.handleChatDraftChange).not.toHaveBeenCalled();
114+});
115+116+it("ignores draft-text with non-string text", () => {
117+const bridge = makeBridge();
118+const host = makeHost();
119+initNativeBridge(host);
120+dispatch(bridge, { type: "draft-text", payload: { text: 42 } });
121+dispatch(bridge, { type: "draft-text", payload: { text: null } });
122+expect(host.handleChatDraftChange).not.toHaveBeenCalled();
123+});
124+125+it("ignores unknown message types", () => {
126+const bridge = makeBridge();
127+const host = makeHost();
128+initNativeBridge(host);
129+dispatch(bridge, { type: "recording-start" });
130+dispatch(bridge, { type: "voice-start" });
131+expect(host.handleChatDraftChange).not.toHaveBeenCalled();
132+});
133+134+it("ignores null, primitives, and messages without a type string", () => {
135+const bridge = makeBridge();
136+const host = makeHost();
137+initNativeBridge(host);
138+dispatch(bridge, null);
139+dispatch(bridge, "string");
140+dispatch(bridge, 42);
141+dispatch(bridge, {});
142+dispatch(bridge, { type: 99 });
143+expect(host.handleChatDraftChange).not.toHaveBeenCalled();
144+});
145+146+it("removes the listener on cleanup", () => {
147+const bridge = makeBridge();
148+const host = makeHost();
149+const cleanup = initNativeBridge(host);
150+expect(bridge.listeners).toHaveLength(1);
151+cleanup();
152+expect(bridge.listeners).toHaveLength(0);
153+expect(bridge.removeEventListener).toHaveBeenCalledWith("message", expect.any(Function));
154+});
155+156+it("does not call handleChatDraftChange after cleanup", () => {
157+const bridge = makeBridge();
158+const host = makeHost();
159+const cleanup = initNativeBridge(host);
160+cleanup();
161+dispatch(bridge, { type: "draft-text", payload: { text: "after cleanup" } });
162+expect(host.handleChatDraftChange).not.toHaveBeenCalled();
163+});
164+165+it("draft-text resets input-history navigation — same effect as a user edit", () => {
166+const bridge = makeBridge();
167+168+const state: ChatInputHistoryState = {
169+sessionKey: "s1",
170+chatLoading: false,
171+chatMessage: "",
172+chatMessages: [],
173+chatLocalInputHistoryBySession: { s1: [{ text: "previous input", ts: 1 }] },
174+chatInputHistorySessionKey: null,
175+chatInputHistoryItems: null,
176+chatInputHistoryIndex: -1,
177+chatDraftBeforeHistory: null,
178+};
179+180+// Simulate the user having navigated into history (index is now active).
181+navigateChatInputHistory(state, "up");
182+expect(state.chatInputHistoryIndex).toBe(0);
183+184+// Host delegates to the real handleChatDraftChange — same path as app.ts.
185+const host = { handleChatDraftChange: (text: string) => applyDraftChange(state, text) };
186+initNativeBridge(host);
187+188+dispatch(bridge, { type: "draft-text", payload: { text: "native injection" } });
189+190+expect(state.chatMessage).toBe("native injection");
191+expect(state.chatInputHistoryIndex).toBe(-1);
192+expect(state.chatInputHistoryItems).toBeNull();
193+expect(state.chatInputHistorySessionKey).toBeNull();
194+});
195+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。