@agentstage/bridge 0.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.
- package/counter/store.json +8 -0
- package/dist/browser/createBridgeStore.d.ts +5 -0
- package/dist/browser/createBridgeStore.d.ts.map +1 -0
- package/dist/browser/createBridgeStore.js +232 -0
- package/dist/browser/createBridgeStore.js.map +1 -0
- package/dist/browser/index.d.ts +4 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/types.d.ts +36 -0
- package/dist/browser/types.d.ts.map +1 -0
- package/dist/browser/types.js +2 -0
- package/dist/browser/types.js.map +1 -0
- package/dist/gateway/apiHandler.d.ts +10 -0
- package/dist/gateway/apiHandler.d.ts.map +1 -0
- package/dist/gateway/apiHandler.js +91 -0
- package/dist/gateway/apiHandler.js.map +1 -0
- package/dist/gateway/createBridgeGateway.d.ts +3 -0
- package/dist/gateway/createBridgeGateway.d.ts.map +1 -0
- package/dist/gateway/createBridgeGateway.js +689 -0
- package/dist/gateway/createBridgeGateway.js.map +1 -0
- package/dist/gateway/fileStore.d.ts +39 -0
- package/dist/gateway/fileStore.d.ts.map +1 -0
- package/dist/gateway/fileStore.js +189 -0
- package/dist/gateway/fileStore.js.map +1 -0
- package/dist/gateway/index.d.ts +6 -0
- package/dist/gateway/index.d.ts.map +1 -0
- package/dist/gateway/index.js +5 -0
- package/dist/gateway/index.js.map +1 -0
- package/dist/gateway/registry.d.ts +21 -0
- package/dist/gateway/registry.d.ts.map +1 -0
- package/dist/gateway/registry.js +136 -0
- package/dist/gateway/registry.js.map +1 -0
- package/dist/gateway/types.d.ts +50 -0
- package/dist/gateway/types.d.ts.map +1 -0
- package/dist/gateway/types.js +2 -0
- package/dist/gateway/types.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/sdk/BridgeClient.d.ts +38 -0
- package/dist/sdk/BridgeClient.d.ts.map +1 -0
- package/dist/sdk/BridgeClient.js +163 -0
- package/dist/sdk/BridgeClient.js.map +1 -0
- package/dist/sdk/index.d.ts +3 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +2 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/shared/types.d.ts +154 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +5 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/utils/logger.d.ts +33 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +206 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/vite/index.d.ts +6 -0
- package/dist/vite/index.d.ts.map +1 -0
- package/dist/vite/index.js +23 -0
- package/dist/vite/index.js.map +1 -0
- package/package.json +60 -0
- package/src/browser/createBridgeStore.ts +276 -0
- package/src/browser/index.ts +6 -0
- package/src/browser/types.ts +36 -0
- package/src/gateway/apiHandler.ts +107 -0
- package/src/gateway/createBridgeGateway.ts +854 -0
- package/src/gateway/fileStore.ts +244 -0
- package/src/gateway/index.ts +12 -0
- package/src/gateway/registry.ts +166 -0
- package/src/gateway/types.ts +65 -0
- package/src/index.ts +33 -0
- package/src/sdk/BridgeClient.ts +203 -0
- package/src/sdk/index.ts +2 -0
- package/src/shared/types.ts +117 -0
- package/src/utils/logger.ts +262 -0
- package/src/vite/index.ts +31 -0
- package/test/e2e/bridge.test.ts +386 -0
- package/test/integration/gateway.test.ts +485 -0
- package/test/mocks/mockWebSocket.ts +49 -0
- package/test/unit/browser.test.ts +267 -0
- package/test/unit/fileStore.test.ts +98 -0
- package/test/unit/registry.test.ts +345 -0
- package/test-page/store.json +8 -0
- package/tsconfig.json +20 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { createBridgeStore } from '../../src/browser/createBridgeStore.js';
|
|
4
|
+
|
|
5
|
+
// Mock global objects
|
|
6
|
+
global.window = {
|
|
7
|
+
location: {
|
|
8
|
+
protocol: 'http:',
|
|
9
|
+
host: 'localhost:3000',
|
|
10
|
+
},
|
|
11
|
+
} as any;
|
|
12
|
+
|
|
13
|
+
class MockWebSocket {
|
|
14
|
+
static CONNECTING = 0;
|
|
15
|
+
static OPEN = 1;
|
|
16
|
+
static CLOSING = 2;
|
|
17
|
+
static CLOSED = 3;
|
|
18
|
+
|
|
19
|
+
public readyState = 0;
|
|
20
|
+
public sentMessages: string[] = [];
|
|
21
|
+
private listeners: Record<string, ((data: any) => void)[]> = {};
|
|
22
|
+
|
|
23
|
+
// Support both onopen/onmessage/onclose/onerror setters and addEventListener
|
|
24
|
+
public onopen: (() => void) | null = null;
|
|
25
|
+
public onmessage: ((event: { data: string }) => void) | null = null;
|
|
26
|
+
public onclose: (() => void) | null = null;
|
|
27
|
+
public onerror: ((err: any) => void) | null = null;
|
|
28
|
+
|
|
29
|
+
constructor(public url: string) {
|
|
30
|
+
// Simulate async open
|
|
31
|
+
queueMicrotask(() => {
|
|
32
|
+
this.readyState = 1;
|
|
33
|
+
if (this.onopen) this.onopen();
|
|
34
|
+
this.emit('open');
|
|
35
|
+
|
|
36
|
+
// Simulate receiving client.setState (hydration) message
|
|
37
|
+
queueMicrotask(() => {
|
|
38
|
+
if (this.onmessage) {
|
|
39
|
+
this.onmessage({
|
|
40
|
+
data: JSON.stringify({
|
|
41
|
+
type: 'client.setState',
|
|
42
|
+
payload: { state: { count: 0 }, expectedVersion: undefined }
|
|
43
|
+
})
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
send(data: string): void {
|
|
51
|
+
this.sentMessages.push(data);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
close(): void {
|
|
55
|
+
this.readyState = 3;
|
|
56
|
+
if (this.onclose) this.onclose();
|
|
57
|
+
this.emit('close');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
addEventListener(event: string, handler: (data: any) => void): void {
|
|
61
|
+
if (!this.listeners[event]) {
|
|
62
|
+
this.listeners[event] = [];
|
|
63
|
+
}
|
|
64
|
+
this.listeners[event].push(handler);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
emit(event: string, data?: any): void {
|
|
68
|
+
this.listeners[event]?.forEach(h => h(data));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
global.WebSocket = MockWebSocket as any;
|
|
73
|
+
|
|
74
|
+
describe('createBridgeStore', () => {
|
|
75
|
+
const schema = z.object({ count: z.number() });
|
|
76
|
+
|
|
77
|
+
describe('store creation', () => {
|
|
78
|
+
it('should create zustand store with initial state', () => {
|
|
79
|
+
const bridge = createBridgeStore({
|
|
80
|
+
pageId: 'test-page',
|
|
81
|
+
description: {
|
|
82
|
+
schema,
|
|
83
|
+
actions: {},
|
|
84
|
+
},
|
|
85
|
+
createState: (set, get) => ({
|
|
86
|
+
count: 0,
|
|
87
|
+
dispatch: () => {},
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const state = bridge.store.getState();
|
|
92
|
+
expect(state.count).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should generate unique storeId with pageId prefix', async () => {
|
|
96
|
+
const bridge = createBridgeStore({
|
|
97
|
+
pageId: 'test-page',
|
|
98
|
+
description: {
|
|
99
|
+
schema,
|
|
100
|
+
actions: {},
|
|
101
|
+
},
|
|
102
|
+
createState: (set, get) => ({
|
|
103
|
+
count: 0,
|
|
104
|
+
dispatch: () => {},
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const connected = await bridge.connect();
|
|
109
|
+
|
|
110
|
+
expect(connected.storeId).toMatch(/^test-page#/);
|
|
111
|
+
expect(typeof connected.storeId).toBe('string');
|
|
112
|
+
expect(connected.storeId.length).toBeGreaterThan('test-page#'.length);
|
|
113
|
+
|
|
114
|
+
connected.disconnect();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should use provided storeKey', () => {
|
|
118
|
+
const bridge = createBridgeStore({
|
|
119
|
+
pageId: 'test-page',
|
|
120
|
+
storeKey: 'sidebar',
|
|
121
|
+
description: {
|
|
122
|
+
schema,
|
|
123
|
+
actions: {},
|
|
124
|
+
},
|
|
125
|
+
createState: (set, get) => ({
|
|
126
|
+
count: 0,
|
|
127
|
+
dispatch: () => {},
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const description = bridge.describes();
|
|
132
|
+
expect(description.storeKey).toBe('sidebar');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should default storeKey to "main"', () => {
|
|
136
|
+
const bridge = createBridgeStore({
|
|
137
|
+
pageId: 'test-page',
|
|
138
|
+
description: {
|
|
139
|
+
schema,
|
|
140
|
+
actions: {},
|
|
141
|
+
},
|
|
142
|
+
createState: (set, get) => ({
|
|
143
|
+
count: 0,
|
|
144
|
+
dispatch: () => {},
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const description = bridge.describes();
|
|
149
|
+
expect(description.storeKey).toBe('main');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('description generation', () => {
|
|
154
|
+
it('should include pageId and storeKey in description', () => {
|
|
155
|
+
const bridge = createBridgeStore({
|
|
156
|
+
pageId: 'test-page',
|
|
157
|
+
storeKey: 'sidebar',
|
|
158
|
+
description: {
|
|
159
|
+
schema,
|
|
160
|
+
actions: {
|
|
161
|
+
increment: {
|
|
162
|
+
description: 'Increment counter',
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
events: {
|
|
166
|
+
update: {
|
|
167
|
+
description: 'Update event',
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
createState: (set, get) => ({
|
|
172
|
+
count: 0,
|
|
173
|
+
dispatch: () => {},
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const description = bridge.describes();
|
|
178
|
+
|
|
179
|
+
expect(description.pageId).toBe('test-page');
|
|
180
|
+
expect(description.storeKey).toBe('sidebar');
|
|
181
|
+
expect(description.actions.increment.description).toBe('Increment counter');
|
|
182
|
+
expect(description.events?.update.description).toBe('Update event');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('connect', () => {
|
|
187
|
+
it('should open WebSocket connection to gateway', async () => {
|
|
188
|
+
const bridge = createBridgeStore({
|
|
189
|
+
pageId: 'test-page',
|
|
190
|
+
description: {
|
|
191
|
+
schema,
|
|
192
|
+
actions: {},
|
|
193
|
+
},
|
|
194
|
+
createState: (set, get) => ({
|
|
195
|
+
count: 0,
|
|
196
|
+
dispatch: () => {},
|
|
197
|
+
}),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const connected = await bridge.connect();
|
|
201
|
+
|
|
202
|
+
expect(bridge.isConnected).toBe(true);
|
|
203
|
+
|
|
204
|
+
connected.disconnect();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should mark as disconnected after disconnect', async () => {
|
|
208
|
+
const bridge = createBridgeStore({
|
|
209
|
+
pageId: 'test-page',
|
|
210
|
+
description: {
|
|
211
|
+
schema,
|
|
212
|
+
actions: {},
|
|
213
|
+
},
|
|
214
|
+
createState: (set, get) => ({
|
|
215
|
+
count: 0,
|
|
216
|
+
dispatch: () => {},
|
|
217
|
+
}),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const connected = await bridge.connect();
|
|
221
|
+
expect(bridge.isConnected).toBe(true);
|
|
222
|
+
|
|
223
|
+
connected.disconnect();
|
|
224
|
+
expect(bridge.isConnected).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('state management', () => {
|
|
229
|
+
it('should update state through zustand', async () => {
|
|
230
|
+
const bridge = createBridgeStore({
|
|
231
|
+
pageId: 'test-page',
|
|
232
|
+
description: {
|
|
233
|
+
schema,
|
|
234
|
+
actions: {},
|
|
235
|
+
},
|
|
236
|
+
createState: (set, get) => ({
|
|
237
|
+
count: 0,
|
|
238
|
+
dispatch: () => {},
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
bridge.store.setState({ count: 5 });
|
|
243
|
+
|
|
244
|
+
expect(bridge.store.getState().count).toBe(5);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should handle dispatch action', async () => {
|
|
248
|
+
const dispatchFn = vi.fn();
|
|
249
|
+
|
|
250
|
+
const bridge = createBridgeStore({
|
|
251
|
+
pageId: 'test-page',
|
|
252
|
+
description: {
|
|
253
|
+
schema,
|
|
254
|
+
actions: {},
|
|
255
|
+
},
|
|
256
|
+
createState: (set, get) => ({
|
|
257
|
+
count: 0,
|
|
258
|
+
dispatch: dispatchFn,
|
|
259
|
+
}),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
bridge.store.getState().dispatch({ type: 'increment', payload: { by: 5 } });
|
|
263
|
+
|
|
264
|
+
expect(dispatchFn).toHaveBeenCalledWith({ type: 'increment', payload: { by: 5 } });
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import {
|
|
6
|
+
FileStore,
|
|
7
|
+
InvalidPageIdError,
|
|
8
|
+
VersionConflictError,
|
|
9
|
+
validatePageId,
|
|
10
|
+
} from '../../src/gateway/fileStore.js';
|
|
11
|
+
|
|
12
|
+
describe('FileStore', () => {
|
|
13
|
+
const tempDirs: string[] = [];
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
for (const dir of tempDirs) {
|
|
17
|
+
try {
|
|
18
|
+
rmSync(dir, { recursive: true, force: true });
|
|
19
|
+
} catch {
|
|
20
|
+
// Ignore cleanup errors in tests.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
tempDirs.length = 0;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function createStore() {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), 'bridge-filestore-'));
|
|
28
|
+
tempDirs.push(dir);
|
|
29
|
+
return new FileStore({ pagesDir: dir });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
it('should reject invalid pageId on validatePageId', () => {
|
|
33
|
+
expect(() => validatePageId('../secrets')).toThrow(InvalidPageIdError);
|
|
34
|
+
expect(() => validatePageId('valid_page-1')).not.toThrow();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should reject invalid pageId on load/save entry', async () => {
|
|
38
|
+
const store = createStore();
|
|
39
|
+
|
|
40
|
+
await expect(store.load('../../etc/passwd')).rejects.toBeInstanceOf(InvalidPageIdError);
|
|
41
|
+
await expect(
|
|
42
|
+
store.save('../../etc/passwd', {
|
|
43
|
+
state: { count: 1 },
|
|
44
|
+
version: 0,
|
|
45
|
+
updatedAt: new Date().toISOString(),
|
|
46
|
+
pageId: '../../etc/passwd',
|
|
47
|
+
})
|
|
48
|
+
).rejects.toBeInstanceOf(InvalidPageIdError);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should enforce CAS with expectedVersion', async () => {
|
|
52
|
+
const store = createStore();
|
|
53
|
+
await store.save('counter', {
|
|
54
|
+
state: { count: 1 },
|
|
55
|
+
version: 0,
|
|
56
|
+
updatedAt: new Date().toISOString(),
|
|
57
|
+
pageId: 'counter',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await expect(
|
|
61
|
+
store.save(
|
|
62
|
+
'counter',
|
|
63
|
+
{
|
|
64
|
+
state: { count: 2 },
|
|
65
|
+
version: 999,
|
|
66
|
+
updatedAt: new Date().toISOString(),
|
|
67
|
+
pageId: 'counter',
|
|
68
|
+
},
|
|
69
|
+
0
|
|
70
|
+
)
|
|
71
|
+
).rejects.toBeInstanceOf(VersionConflictError);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should assign monotonic versions from server', async () => {
|
|
75
|
+
const store = createStore();
|
|
76
|
+
|
|
77
|
+
const first = await store.save('counter', {
|
|
78
|
+
state: { count: 1 },
|
|
79
|
+
version: 999,
|
|
80
|
+
updatedAt: new Date().toISOString(),
|
|
81
|
+
pageId: 'counter',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const second = await store.save(
|
|
85
|
+
'counter',
|
|
86
|
+
{
|
|
87
|
+
state: { count: 2 },
|
|
88
|
+
version: 0,
|
|
89
|
+
updatedAt: new Date().toISOString(),
|
|
90
|
+
pageId: 'counter',
|
|
91
|
+
},
|
|
92
|
+
first.version
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(first.version).toBe(1);
|
|
96
|
+
expect(second.version).toBe(2);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { WebSocket } from 'ws';
|
|
3
|
+
import { StoreRegistry } from '../../src/gateway/registry.js';
|
|
4
|
+
import type { RegisteredStore, StoreChangeEvent } from '../../src/gateway/types.js';
|
|
5
|
+
|
|
6
|
+
// Mock WebSocket
|
|
7
|
+
vi.mock('ws', () => ({
|
|
8
|
+
WebSocket: class MockWebSocket {
|
|
9
|
+
readyState = 1; // OPEN
|
|
10
|
+
static CONNECTING = 0;
|
|
11
|
+
static OPEN = 1;
|
|
12
|
+
static CLOSING = 2;
|
|
13
|
+
static CLOSED = 3;
|
|
14
|
+
|
|
15
|
+
sentMessages: unknown[] = [];
|
|
16
|
+
|
|
17
|
+
send(data: string): void {
|
|
18
|
+
this.sentMessages.push(data);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
close(): void {
|
|
22
|
+
this.readyState = 3;
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
function createMockStore(overrides: Partial<RegisteredStore> = {}): RegisteredStore {
|
|
28
|
+
const ws = new WebSocket('ws://localhost:3000/_bridge');
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
id: 'page#abc123',
|
|
32
|
+
pageId: 'page',
|
|
33
|
+
storeKey: 'main',
|
|
34
|
+
description: {
|
|
35
|
+
pageId: 'page',
|
|
36
|
+
storeKey: 'main',
|
|
37
|
+
schema: { type: 'object' },
|
|
38
|
+
actions: {},
|
|
39
|
+
},
|
|
40
|
+
currentState: { count: 0 },
|
|
41
|
+
version: 0,
|
|
42
|
+
ws,
|
|
43
|
+
subscribers: new Set(),
|
|
44
|
+
connectedAt: new Date(),
|
|
45
|
+
lastActivity: new Date(),
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('StoreRegistry', () => {
|
|
51
|
+
let registry: StoreRegistry;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
registry = new StoreRegistry();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('register', () => {
|
|
58
|
+
it('should register a store and make it retrievable by id', () => {
|
|
59
|
+
const store = createMockStore({ id: 'page#abc123' });
|
|
60
|
+
|
|
61
|
+
registry.register(store);
|
|
62
|
+
|
|
63
|
+
const retrieved = registry.get('page#abc123');
|
|
64
|
+
expect(retrieved).toBe(store);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should index store by pageId + storeKey', () => {
|
|
68
|
+
const store = createMockStore({
|
|
69
|
+
id: 'page#abc123',
|
|
70
|
+
pageId: 'test-page',
|
|
71
|
+
storeKey: 'main',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
registry.register(store);
|
|
75
|
+
|
|
76
|
+
const found = registry.find('test-page', 'main');
|
|
77
|
+
expect(found).toBe(store);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should replace existing store with same pageId+storeKey and disconnect old', () => {
|
|
81
|
+
const oldStore = createMockStore({
|
|
82
|
+
id: 'page#old',
|
|
83
|
+
pageId: 'test-page',
|
|
84
|
+
storeKey: 'main',
|
|
85
|
+
});
|
|
86
|
+
const newStore = createMockStore({
|
|
87
|
+
id: 'page#new',
|
|
88
|
+
pageId: 'test-page',
|
|
89
|
+
storeKey: 'main',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
registry.register(oldStore);
|
|
93
|
+
registry.register(newStore);
|
|
94
|
+
|
|
95
|
+
// Old store should be removed
|
|
96
|
+
expect(registry.get('page#old')).toBeUndefined();
|
|
97
|
+
// New store should be available
|
|
98
|
+
expect(registry.get('page#new')).toBe(newStore);
|
|
99
|
+
// Index should point to new store
|
|
100
|
+
expect(registry.find('test-page', 'main')).toBe(newStore);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should group stores by pageId', () => {
|
|
104
|
+
const store1 = createMockStore({ id: 'page#1', pageId: 'page-a', storeKey: 'main' });
|
|
105
|
+
const store2 = createMockStore({ id: 'page#2', pageId: 'page-a', storeKey: 'secondary' });
|
|
106
|
+
const store3 = createMockStore({ id: 'page#3', pageId: 'page-b', storeKey: 'main' });
|
|
107
|
+
|
|
108
|
+
registry.register(store1);
|
|
109
|
+
registry.register(store2);
|
|
110
|
+
registry.register(store3);
|
|
111
|
+
|
|
112
|
+
const pageAStores = registry.findByPage('page-a');
|
|
113
|
+
expect(pageAStores).toHaveLength(2);
|
|
114
|
+
expect(pageAStores).toContain(store1);
|
|
115
|
+
expect(pageAStores).toContain(store2);
|
|
116
|
+
|
|
117
|
+
const pageBStores = registry.findByPage('page-b');
|
|
118
|
+
expect(pageBStores).toHaveLength(1);
|
|
119
|
+
expect(pageBStores).toContain(store3);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should emit stateChanged event on register', () => {
|
|
123
|
+
const store = createMockStore({ id: 'page#abc123', currentState: { count: 5 } });
|
|
124
|
+
const handler = vi.fn();
|
|
125
|
+
|
|
126
|
+
registry.onChange(handler);
|
|
127
|
+
registry.register(store);
|
|
128
|
+
|
|
129
|
+
expect(handler).toHaveBeenCalledWith({
|
|
130
|
+
type: 'stateChanged',
|
|
131
|
+
storeId: 'page#abc123',
|
|
132
|
+
state: { count: 5 },
|
|
133
|
+
version: 0,
|
|
134
|
+
source: 'register',
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('updateState', () => {
|
|
140
|
+
it('should update store state and version', () => {
|
|
141
|
+
const store = createMockStore({ id: 'page#abc123' });
|
|
142
|
+
registry.register(store);
|
|
143
|
+
|
|
144
|
+
registry.updateState('page#abc123', { count: 10 }, 1);
|
|
145
|
+
|
|
146
|
+
const updated = registry.get('page#abc123');
|
|
147
|
+
expect(updated?.currentState).toEqual({ count: 10 });
|
|
148
|
+
expect(updated?.version).toBe(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should update lastActivity timestamp', () => {
|
|
152
|
+
const store = createMockStore({ id: 'page#abc123' });
|
|
153
|
+
const oldActivity = store.lastActivity.getTime();
|
|
154
|
+
|
|
155
|
+
registry.register(store);
|
|
156
|
+
|
|
157
|
+
// Wait a bit (using real time)
|
|
158
|
+
const start = Date.now();
|
|
159
|
+
while (Date.now() - start < 10) {} // Small delay
|
|
160
|
+
|
|
161
|
+
registry.updateState('page#abc123', { count: 10 }, 1);
|
|
162
|
+
|
|
163
|
+
const updated = registry.get('page#abc123');
|
|
164
|
+
expect(updated?.lastActivity.getTime()).toBeGreaterThanOrEqual(oldActivity);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should emit stateChanged event', () => {
|
|
168
|
+
const store = createMockStore({ id: 'page#abc123' });
|
|
169
|
+
const handler = vi.fn();
|
|
170
|
+
|
|
171
|
+
registry.register(store);
|
|
172
|
+
registry.onChange(handler);
|
|
173
|
+
registry.updateState('page#abc123', { count: 10 }, 1);
|
|
174
|
+
|
|
175
|
+
expect(handler).toHaveBeenCalledWith({
|
|
176
|
+
type: 'stateChanged',
|
|
177
|
+
storeId: 'page#abc123',
|
|
178
|
+
state: { count: 10 },
|
|
179
|
+
version: 1,
|
|
180
|
+
source: 'browser',
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should do nothing if store does not exist', () => {
|
|
185
|
+
const handler = vi.fn();
|
|
186
|
+
registry.onChange(handler);
|
|
187
|
+
|
|
188
|
+
registry.updateState('non-existent', { count: 10 }, 1);
|
|
189
|
+
|
|
190
|
+
expect(handler).not.toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('disconnect', () => {
|
|
195
|
+
it('should remove store from registry', () => {
|
|
196
|
+
const store = createMockStore({ id: 'page#abc123' });
|
|
197
|
+
registry.register(store);
|
|
198
|
+
|
|
199
|
+
registry.disconnect('page#abc123', 'test');
|
|
200
|
+
|
|
201
|
+
expect(registry.get('page#abc123')).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should remove from page index', () => {
|
|
205
|
+
const store = createMockStore({ id: 'page#abc123', pageId: 'test-page', storeKey: 'main' });
|
|
206
|
+
registry.register(store);
|
|
207
|
+
|
|
208
|
+
registry.disconnect('page#abc123', 'test');
|
|
209
|
+
|
|
210
|
+
expect(registry.find('test-page', 'main')).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should remove from page grouping', () => {
|
|
214
|
+
const store = createMockStore({ id: 'page#abc123', pageId: 'test-page', storeKey: 'main' });
|
|
215
|
+
registry.register(store);
|
|
216
|
+
|
|
217
|
+
registry.disconnect('page#abc123', 'test');
|
|
218
|
+
|
|
219
|
+
expect(registry.findByPage('test-page')).toHaveLength(0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should notify subscribers with disconnected message', () => {
|
|
223
|
+
const store = createMockStore({ id: 'page#abc123' });
|
|
224
|
+
const subscriberWs = new WebSocket('ws://localhost');
|
|
225
|
+
store.subscribers.add(subscriberWs);
|
|
226
|
+
|
|
227
|
+
registry.register(store);
|
|
228
|
+
registry.disconnect('page#abc123', 'client_disconnect');
|
|
229
|
+
|
|
230
|
+
// Check that message was sent to subscriber
|
|
231
|
+
const messages = (subscriberWs as any).sentMessages;
|
|
232
|
+
expect(messages).toHaveLength(1);
|
|
233
|
+
const message = JSON.parse(messages[0] as string);
|
|
234
|
+
expect(message.type).toBe('store.disconnected');
|
|
235
|
+
expect(message.payload).toEqual({
|
|
236
|
+
storeId: 'page#abc123',
|
|
237
|
+
reason: 'client_disconnect',
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should emit disconnected event', () => {
|
|
242
|
+
const store = createMockStore({ id: 'page#abc123' });
|
|
243
|
+
const handler = vi.fn();
|
|
244
|
+
|
|
245
|
+
registry.register(store);
|
|
246
|
+
registry.onChange(handler);
|
|
247
|
+
registry.disconnect('page#abc123', 'test');
|
|
248
|
+
|
|
249
|
+
expect(handler).toHaveBeenCalledWith({
|
|
250
|
+
type: 'disconnected',
|
|
251
|
+
storeId: 'page#abc123',
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe('addSubscriber', () => {
|
|
257
|
+
it('should add subscriber to store', () => {
|
|
258
|
+
const store = createMockStore({ id: 'page#abc123' });
|
|
259
|
+
const subscriberWs = new WebSocket('ws://localhost');
|
|
260
|
+
|
|
261
|
+
registry.register(store);
|
|
262
|
+
registry.addSubscriber('page#abc123', subscriberWs);
|
|
263
|
+
|
|
264
|
+
expect(store.subscribers.has(subscriberWs)).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should send current state snapshot to subscriber', () => {
|
|
268
|
+
const store = createMockStore({
|
|
269
|
+
id: 'page#abc123',
|
|
270
|
+
currentState: { count: 42 },
|
|
271
|
+
version: 5,
|
|
272
|
+
});
|
|
273
|
+
const subscriberWs = new WebSocket('ws://localhost');
|
|
274
|
+
|
|
275
|
+
registry.register(store);
|
|
276
|
+
registry.addSubscriber('page#abc123', subscriberWs);
|
|
277
|
+
|
|
278
|
+
const messages = (subscriberWs as any).sentMessages;
|
|
279
|
+
expect(messages).toHaveLength(1);
|
|
280
|
+
const message = JSON.parse(messages[0] as string);
|
|
281
|
+
expect(message.type).toBe('store.stateChanged');
|
|
282
|
+
expect(message.payload.state).toEqual({ count: 42 });
|
|
283
|
+
expect(message.payload.version).toBe(5);
|
|
284
|
+
expect(message.payload.source).toBe('snapshot');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should throw if store does not exist', () => {
|
|
288
|
+
const subscriberWs = new WebSocket('ws://localhost');
|
|
289
|
+
|
|
290
|
+
expect(() => registry.addSubscriber('non-existent', subscriberWs)).toThrow('Store not found');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should return unsubscribe function', () => {
|
|
294
|
+
const store = createMockStore({ id: 'page#abc123' });
|
|
295
|
+
const subscriberWs = new WebSocket('ws://localhost');
|
|
296
|
+
|
|
297
|
+
registry.register(store);
|
|
298
|
+
const unsubscribe = registry.addSubscriber('page#abc123', subscriberWs);
|
|
299
|
+
|
|
300
|
+
expect(store.subscribers.has(subscriberWs)).toBe(true);
|
|
301
|
+
|
|
302
|
+
unsubscribe();
|
|
303
|
+
|
|
304
|
+
expect(store.subscribers.has(subscriberWs)).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe('cleanup', () => {
|
|
309
|
+
it('should remove closed WebSocket subscribers', () => {
|
|
310
|
+
const store = createMockStore({ id: 'page#abc123' });
|
|
311
|
+
const closedWs = new WebSocket('ws://localhost');
|
|
312
|
+
const openWs = new WebSocket('ws://localhost');
|
|
313
|
+
|
|
314
|
+
// Simulate closed state
|
|
315
|
+
(closedWs as any).readyState = 3; // CLOSED
|
|
316
|
+
|
|
317
|
+
store.subscribers.add(closedWs);
|
|
318
|
+
store.subscribers.add(openWs);
|
|
319
|
+
|
|
320
|
+
registry.register(store);
|
|
321
|
+
registry.cleanup();
|
|
322
|
+
|
|
323
|
+
expect(store.subscribers.has(closedWs)).toBe(false);
|
|
324
|
+
expect(store.subscribers.has(openWs)).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('onChange', () => {
|
|
329
|
+
it('should return unsubscribe function', () => {
|
|
330
|
+
const store = createMockStore({ id: 'page#abc123' });
|
|
331
|
+
const handler = vi.fn();
|
|
332
|
+
|
|
333
|
+
const unsubscribe = registry.onChange(handler);
|
|
334
|
+
|
|
335
|
+
registry.register(store);
|
|
336
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
337
|
+
|
|
338
|
+
unsubscribe();
|
|
339
|
+
|
|
340
|
+
registry.updateState('page#abc123', { count: 10 }, 1);
|
|
341
|
+
// Should not be called again
|
|
342
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
});
|