Skip to content

Commit 397f934

Browse files
authored
Merge pull request #354 from epilot-dev/feat/app-bridge-v1
@epilot/app-bridge@0.1.0
2 parents 6ee1876 + 2ab718c commit 397f934

File tree

9 files changed

+1707
-26
lines changed

9 files changed

+1707
-26
lines changed

packages/app-bridge/README.md

Lines changed: 437 additions & 3 deletions
Large diffs are not rendered by default.

packages/app-bridge/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@epilot/app-bridge",
3-
"version": "0.0.1-alpha.8",
3+
"version": "0.1.0",
44
"type": "module",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",
Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import {
3+
authorizeClient,
4+
getActionConfig,
5+
getEntityContext,
6+
getSession,
7+
initialize,
8+
isInitialized,
9+
on,
10+
onVisibilityChange,
11+
send,
12+
updateActionConfig,
13+
updateContentHeight,
14+
__reset,
15+
} from './app-bridge';
16+
import {
17+
AppBridgeNotInitializedError,
18+
AppBridgeTimeoutError,
19+
} from './errors';
20+
21+
describe('app-bridge', () => {
22+
let messageHandlers: Map<string, (event: MessageEvent) => void>;
23+
let postMessageMock: ReturnType<typeof vi.fn>;
24+
25+
beforeEach(() => {
26+
__reset();
27+
messageHandlers = new Map();
28+
29+
// Mock window.addEventListener
30+
vi.spyOn(window, 'addEventListener').mockImplementation((event, handler) => {
31+
if (event === 'message') {
32+
messageHandlers.set(event, handler as (event: MessageEvent) => void);
33+
}
34+
});
35+
36+
vi.spyOn(window, 'removeEventListener').mockImplementation((event) => {
37+
if (event === 'message') {
38+
messageHandlers.delete(event);
39+
}
40+
});
41+
42+
// Mock window.parent.postMessage
43+
postMessageMock = vi.fn();
44+
vi.stubGlobal('parent', { postMessage: postMessageMock });
45+
46+
// Mock document.body.scrollHeight
47+
Object.defineProperty(document.body, 'scrollHeight', {
48+
value: 500,
49+
configurable: true,
50+
});
51+
});
52+
53+
afterEach(() => {
54+
vi.restoreAllMocks();
55+
vi.unstubAllGlobals();
56+
});
57+
58+
const simulateParentMessage = (event: string, data: Record<string, unknown>) => {
59+
const handler = messageHandlers.get('message');
60+
if (handler) {
61+
handler({
62+
data: { event, ...data },
63+
source: window.parent,
64+
} as MessageEvent);
65+
}
66+
};
67+
68+
describe('initialize', () => {
69+
it('should send app-bridge:init message and resolve with session', async () => {
70+
const initPromise = initialize();
71+
72+
// Simulate parent response
73+
setTimeout(() => {
74+
simulateParentMessage('app-bridge:init', { token: 'test-token', lang: 'en' });
75+
}, 10);
76+
77+
const session = await initPromise;
78+
79+
expect(postMessageMock).toHaveBeenCalledWith(
80+
expect.objectContaining({
81+
source: 'app-bridge',
82+
event: 'app-bridge:init',
83+
contentHeight: 500,
84+
}),
85+
'*',
86+
);
87+
expect(session).toEqual({ token: 'test-token', lang: 'en' });
88+
});
89+
90+
it('should return cached session on subsequent calls', async () => {
91+
const initPromise = initialize();
92+
93+
setTimeout(() => {
94+
simulateParentMessage('app-bridge:init', { token: 'test-token', lang: 'en' });
95+
}, 10);
96+
97+
const session1 = await initPromise;
98+
const session2 = await initialize();
99+
100+
expect(session1).toBe(session2);
101+
expect(postMessageMock).toHaveBeenCalledTimes(1);
102+
});
103+
104+
it('should deduplicate concurrent initialization calls', async () => {
105+
const promise1 = initialize();
106+
const promise2 = initialize();
107+
108+
setTimeout(() => {
109+
simulateParentMessage('app-bridge:init', { token: 'test-token', lang: 'en' });
110+
}, 10);
111+
112+
const [session1, session2] = await Promise.all([promise1, promise2]);
113+
114+
expect(session1).toBe(session2);
115+
expect(postMessageMock).toHaveBeenCalledTimes(1);
116+
});
117+
118+
it('should respect custom contentHeight option', async () => {
119+
const initPromise = initialize({ contentHeight: 1000 });
120+
121+
setTimeout(() => {
122+
simulateParentMessage('app-bridge:init', { token: 'test-token', lang: 'en' });
123+
}, 10);
124+
125+
await initPromise;
126+
127+
expect(postMessageMock).toHaveBeenCalledWith(
128+
expect.objectContaining({ contentHeight: 1000 }),
129+
'*',
130+
);
131+
});
132+
133+
it('should timeout if no response received', async () => {
134+
await expect(initialize({ timeout: 50 })).rejects.toThrow(AppBridgeTimeoutError);
135+
});
136+
});
137+
138+
describe('getSession', () => {
139+
it('should throw if not initialized', () => {
140+
expect(() => getSession()).toThrow(AppBridgeNotInitializedError);
141+
});
142+
143+
it('should return session after initialization', async () => {
144+
const initPromise = initialize();
145+
146+
setTimeout(() => {
147+
simulateParentMessage('app-bridge:init', { token: 'test-token', lang: 'de' });
148+
}, 10);
149+
150+
await initPromise;
151+
152+
const session = getSession();
153+
expect(session).toEqual({ token: 'test-token', lang: 'de' });
154+
});
155+
});
156+
157+
describe('isInitialized', () => {
158+
it('should return false before initialization', () => {
159+
expect(isInitialized()).toBe(false);
160+
});
161+
162+
it('should return true after initialization', async () => {
163+
const initPromise = initialize();
164+
165+
setTimeout(() => {
166+
simulateParentMessage('app-bridge:init', { token: 'test-token', lang: 'en' });
167+
}, 10);
168+
169+
await initPromise;
170+
171+
expect(isInitialized()).toBe(true);
172+
});
173+
});
174+
175+
describe('getEntityContext', () => {
176+
it('should request and return entity context', async () => {
177+
const contextPromise = getEntityContext();
178+
179+
setTimeout(() => {
180+
simulateParentMessage('init-context', {
181+
context: {
182+
entityId: '123',
183+
schema: 'contact',
184+
capability: { name: 'my-capability' },
185+
},
186+
});
187+
}, 10);
188+
189+
const context = await contextPromise;
190+
191+
expect(postMessageMock).toHaveBeenCalledWith(
192+
expect.objectContaining({
193+
source: 'app-bridge',
194+
event: 'init-context',
195+
}),
196+
'*',
197+
);
198+
expect(context).toEqual({
199+
entityId: '123',
200+
schema: 'contact',
201+
capability: { name: 'my-capability' },
202+
});
203+
});
204+
205+
it('should timeout if no response', async () => {
206+
await expect(getEntityContext({ timeout: 50 })).rejects.toThrow(AppBridgeTimeoutError);
207+
});
208+
});
209+
210+
describe('updateContentHeight', () => {
211+
it('should send update-content-height message', () => {
212+
updateContentHeight(800);
213+
214+
expect(postMessageMock).toHaveBeenCalledWith(
215+
expect.objectContaining({
216+
source: 'app-bridge',
217+
event: 'update-content-height',
218+
contentHeight: 800,
219+
}),
220+
'*',
221+
);
222+
});
223+
});
224+
225+
describe('onVisibilityChange', () => {
226+
it('should subscribe to visibility changes', () => {
227+
const handler = vi.fn();
228+
onVisibilityChange(handler);
229+
230+
simulateParentMessage('visibility-change', { isVisible: false });
231+
232+
expect(handler).toHaveBeenCalledWith(false);
233+
});
234+
235+
it('should return unsubscribe function', () => {
236+
const handler = vi.fn();
237+
const unsubscribe = onVisibilityChange(handler);
238+
239+
unsubscribe();
240+
241+
simulateParentMessage('visibility-change', { isVisible: true });
242+
243+
expect(handler).not.toHaveBeenCalled();
244+
});
245+
});
246+
247+
describe('getActionConfig', () => {
248+
it('should request and return action config', async () => {
249+
interface MyConfig {
250+
subscriptionId: string;
251+
}
252+
253+
const configPromise = getActionConfig<MyConfig>();
254+
255+
setTimeout(() => {
256+
simulateParentMessage('init-action-config', {
257+
config: {
258+
custom_action_config: { subscriptionId: 'sub-123' },
259+
description: 'Test action',
260+
},
261+
});
262+
}, 10);
263+
264+
const config = await configPromise;
265+
266+
expect(postMessageMock).toHaveBeenCalledWith(
267+
expect.objectContaining({
268+
source: 'app-bridge',
269+
event: 'init-action-config',
270+
}),
271+
'*',
272+
);
273+
expect(config.custom_action_config?.subscriptionId).toBe('sub-123');
274+
expect(config.description).toBe('Test action');
275+
});
276+
});
277+
278+
describe('updateActionConfig', () => {
279+
it('should send update-action-config message', () => {
280+
updateActionConfig({ webhookUrl: 'https://example.com' });
281+
282+
expect(postMessageMock).toHaveBeenCalledWith(
283+
expect.objectContaining({
284+
source: 'app-bridge',
285+
event: 'update-action-config',
286+
config: { webhookUrl: 'https://example.com' },
287+
}),
288+
'*',
289+
);
290+
});
291+
292+
it('should include wait_for_callback option', () => {
293+
updateActionConfig({ webhookUrl: 'https://example.com' }, { waitForCallback: true });
294+
295+
expect(postMessageMock).toHaveBeenCalledWith(
296+
expect.objectContaining({
297+
source: 'app-bridge',
298+
event: 'update-action-config',
299+
config: { webhookUrl: 'https://example.com' },
300+
wait_for_callback: true,
301+
}),
302+
'*',
303+
);
304+
});
305+
});
306+
307+
describe('on', () => {
308+
it('should subscribe to custom events', () => {
309+
const handler = vi.fn();
310+
on('custom-event', handler);
311+
312+
simulateParentMessage('custom-event', { value: 42 });
313+
314+
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ value: 42 }));
315+
});
316+
317+
it('should return unsubscribe function', () => {
318+
const handler = vi.fn();
319+
const unsubscribe = on('custom-event', handler);
320+
321+
unsubscribe();
322+
323+
simulateParentMessage('custom-event', { value: 42 });
324+
325+
expect(handler).not.toHaveBeenCalled();
326+
});
327+
});
328+
329+
describe('send', () => {
330+
it('should send custom messages', () => {
331+
send('custom-event', { action: 'test' });
332+
333+
expect(postMessageMock).toHaveBeenCalledWith(
334+
expect.objectContaining({
335+
source: 'app-bridge',
336+
event: 'custom-event',
337+
action: 'test',
338+
}),
339+
'*',
340+
);
341+
});
342+
});
343+
344+
describe('authorizeClient', () => {
345+
it('should set Authorization header from session object', () => {
346+
const mockClient = {
347+
defaults: {
348+
headers: {
349+
common: {} as Record<string, unknown>,
350+
},
351+
},
352+
};
353+
354+
authorizeClient(mockClient, { token: 'test-token', lang: 'en' });
355+
356+
expect(mockClient.defaults.headers.common.Authorization).toBe('Bearer test-token');
357+
});
358+
359+
it('should set Authorization header from token string', () => {
360+
const mockClient = {
361+
defaults: {
362+
headers: {
363+
common: {} as Record<string, unknown>,
364+
},
365+
},
366+
};
367+
368+
authorizeClient(mockClient, 'my-token');
369+
370+
expect(mockClient.defaults.headers.common.Authorization).toBe('Bearer my-token');
371+
});
372+
373+
it('should preserve existing headers', () => {
374+
const mockClient = {
375+
defaults: {
376+
headers: {
377+
common: {
378+
'X-Custom-Header': 'custom-value',
379+
} as Record<string, unknown>,
380+
},
381+
},
382+
};
383+
384+
authorizeClient(mockClient, 'test-token');
385+
386+
expect(mockClient.defaults.headers.common.Authorization).toBe('Bearer test-token');
387+
expect(mockClient.defaults.headers.common['X-Custom-Header']).toBe('custom-value');
388+
});
389+
});
390+
});

0 commit comments

Comments
 (0)