@typexim/web-sdk 0.0.6 → 0.0.8

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 (45) hide show
  1. package/README.md +3 -1
  2. package/dist/types/enums/sdk.d.ts +0 -1
  3. package/dist/types/modules/ChatSDK.d.ts +8 -2
  4. package/dist/types/modules/ReConnectWebsocket.d.ts +3 -0
  5. package/dist/types/modules/TabSessionCoordinator.d.ts +9 -5
  6. package/dist/types/utils/websocket.d.ts +4 -0
  7. package/dist/web-sdk.esm.js +1 -1
  8. package/dist/web-sdk.js +1 -1
  9. package/dist-test/enums/business.js +139 -0
  10. package/dist-test/enums/index.js +2 -0
  11. package/dist-test/enums/sdk.js +24 -0
  12. package/dist-test/events.js +50 -0
  13. package/dist-test/index.js +26 -0
  14. package/dist-test/interfaces/business.js +1 -0
  15. package/dist-test/interfaces/index.js +3 -0
  16. package/dist-test/interfaces/pushMessage.js +1 -0
  17. package/dist-test/interfaces/sdk/api.js +1 -0
  18. package/dist-test/interfaces/sdk/common.js +1 -0
  19. package/dist-test/interfaces/sdk/index.js +2 -0
  20. package/dist-test/modules/Base.js +70 -0
  21. package/dist-test/modules/BusinessWebsocket.js +68 -0
  22. package/dist-test/modules/ChatSDK.js +698 -0
  23. package/dist-test/modules/FeedCache.js +211 -0
  24. package/dist-test/modules/HttpClient.js +104 -0
  25. package/dist-test/modules/Logger.js +73 -0
  26. package/dist-test/modules/PushMessageProcessor.js +437 -0
  27. package/dist-test/modules/ReConnectWebsocket.js +223 -0
  28. package/dist-test/modules/Storage.js +27 -0
  29. package/dist-test/modules/TabSessionCoordinator.js +289 -0
  30. package/dist-test/modules/Version.js +20 -0
  31. package/dist-test/utils/date.js +4 -0
  32. package/dist-test/utils/http.js +13 -0
  33. package/dist-test/utils/makeSign.js +79 -0
  34. package/dist-test/utils/message.js +146 -0
  35. package/dist-test/utils/misc.js +13 -0
  36. package/dist-test/utils/promise.js +10 -0
  37. package/dist-test/utils/sleep.js +3 -0
  38. package/dist-test/utils/websocket.js +24 -0
  39. package/package.json +1 -1
  40. package/test/{chat-sdk-restore-login.test.mjs → chat-sdk-independent-login.test.mjs} +50 -62
  41. package/test/chat-sdk-session-restore.test.mjs +331 -0
  42. package/test/tab-session-coordinator.test.mjs +65 -64
  43. package/test/websocket-url.test.mjs +24 -0
  44. package/dist/types/modules/SingletonLock.d.ts +0 -12
  45. package/test/singleton-lock.test.mjs +0 -75
@@ -47,44 +47,20 @@ function installBrowserMocks() {
47
47
  storageListeners.clear();
48
48
  storageData.clear();
49
49
 
50
- let lockHeld = false;
51
50
  Object.defineProperty(globalThis, 'navigator', {
52
51
  configurable: true,
53
- value: {
54
- locks: {
55
- request: async (_name, options, callback) => {
56
- if (lockHeld && options?.ifAvailable) {
57
- return callback(null);
58
- }
59
-
60
- lockHeld = true;
61
- try {
62
- return await callback({});
63
- } finally {
64
- lockHeld = false;
65
- }
66
- },
67
- },
68
- },
52
+ value: {},
69
53
  });
70
54
  globalThis.BroadcastChannel = MockBroadcastChannel;
71
55
  globalThis.WebSocket = MockWebSocket;
72
56
  globalThis.window = {
73
- addEventListener: (type, listener) => {
74
- if (type === 'storage') storageListeners.add(listener);
75
- },
76
- removeEventListener: (type, listener) => {
77
- if (type === 'storage') storageListeners.delete(listener);
78
- },
57
+ addEventListener: () => {},
58
+ removeEventListener: () => {},
79
59
  };
80
60
  globalThis.localStorage = {
81
61
  getItem: (key) => storageData.get(key) ?? null,
82
62
  setItem: (key, value) => {
83
- const oldValue = storageData.get(key) ?? null;
84
63
  storageData.set(key, value);
85
- for (const listener of storageListeners) {
86
- listener({ key, oldValue, newValue: value });
87
- }
88
64
  },
89
65
  removeItem: (key) => storageData.delete(key),
90
66
  };
@@ -177,56 +153,68 @@ async function patchRelativeImports(source, sourceUrl) {
177
153
  return patched;
178
154
  }
179
155
 
180
- test('login restores an active tab session before calling session login API', async () => {
156
+ test('two SDK instances can both be ready without a cross-tab lock', async () => {
181
157
  installBrowserMocks();
182
158
 
183
159
  await patchDistTestImports(new URL('../dist-test/', import.meta.url));
184
160
  const { default: ChatSDK } = await import('../dist-test/modules/ChatSDK.js');
185
161
 
186
- const oldSdk = createSdk(ChatSDK);
187
- oldSdk.context.deviceId = 'device-1';
188
- oldSdk.context.isLogin = true;
189
- oldSdk.context.isReady = true;
190
- oldSdk.context.loginUser = {
191
- userId: 'user-1',
192
- secretKey: 'secret-1',
193
- sessionId: 'session-1',
194
- };
195
- await oldSdk.singletonLock.requestLock();
162
+ const firstSdk = createSdk(ChatSDK);
163
+ const secondSdk = createSdk(ChatSDK);
164
+
165
+ let sessionLoginCalls = 0;
166
+
167
+ const mockPost = async (url, body) => {
168
+ if (url === '/device_api/device_id') {
169
+ return {
170
+ ok: true,
171
+ data: 'device-shared',
172
+ };
173
+ }
196
174
 
197
- const newSdk = createSdk(ChatSDK);
198
- let sessionLoginApiCalled = false;
199
- newSdk.httpClient.post = async (url) => {
200
175
  if (url === '/user/session/login') {
201
- sessionLoginApiCalled = true;
176
+ sessionLoginCalls += 1;
177
+ return {
178
+ ok: true,
179
+ data: {
180
+ id: `user-${body.userCode}`,
181
+ key: `secret-${body.userCode}`,
182
+ },
183
+ };
202
184
  }
185
+
203
186
  return {
204
187
  ok: false,
205
188
  code: -1,
206
- msg: 'unexpected post',
189
+ msg: `unexpected post ${url}`,
207
190
  };
208
191
  };
209
192
 
193
+ firstSdk.httpClient.post = mockPost;
194
+ secondSdk.httpClient.post = mockPost;
195
+
210
196
  try {
211
- const res = await newSdk.login({
212
- user_code: 'session-1',
213
- });
214
-
215
- assert.equal(res.ok, true);
216
- assert.equal(sessionLoginApiCalled, false);
217
- assert.equal(newSdk.context.isLogin, true);
218
- assert.equal(newSdk.context.isReady, true);
219
- assert.deepEqual(newSdk.context.loginUser, {
220
- userId: 'user-1',
221
- secretKey: 'secret-1',
222
- sessionId: 'session-1',
223
- });
224
- assert.equal(oldSdk.context.isLogin, false);
225
- assert.equal(oldSdk.context.isReady, false);
197
+ // Wait for auto-restore timeouts so login does not share an in-flight restore.
198
+ await new Promise((resolve) => setTimeout(resolve, 20));
199
+
200
+ const firstRes = await firstSdk.login({ user_code: 'code-a' });
201
+ await new Promise((resolve) => setTimeout(resolve, 0));
202
+ const secondRes = await secondSdk.login({ user_code: 'code-a' });
203
+
204
+ assert.equal(firstRes.ok, true);
205
+ assert.equal(secondRes.ok, true);
206
+ // First login broadcasts session; second tab is already logged in and skips API.
207
+ assert.equal(sessionLoginCalls, 1);
208
+ assert.equal(firstSdk.context.isLogin, true);
209
+ assert.equal(firstSdk.context.isReady, true);
210
+ assert.equal(secondSdk.context.isLogin, true);
211
+ assert.equal(secondSdk.context.isReady, true);
212
+ assert.equal(firstSdk.context.loginUser?.sessionId, 'code-a');
213
+ assert.equal(secondSdk.context.loginUser?.sessionId, 'code-a');
226
214
  } finally {
227
- await oldSdk.afterLogout();
228
- await newSdk.afterLogout();
229
- oldSdk.tabSessionCoordinator.destroy();
230
- newSdk.tabSessionCoordinator.destroy();
215
+ firstSdk.afterLogout();
216
+ secondSdk.afterLogout();
217
+ firstSdk.tabSessionCoordinator.destroy();
218
+ secondSdk.tabSessionCoordinator.destroy();
231
219
  }
232
220
  });
@@ -0,0 +1,331 @@
1
+ import assert from 'node:assert/strict';
2
+ import { access, readdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { test } from 'node:test';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const channels = new Set();
8
+ const storageListeners = new Set();
9
+ const storageData = new Map();
10
+
11
+ class MockBroadcastChannel {
12
+ constructor(name) {
13
+ this.name = name;
14
+ this.onmessage = null;
15
+ channels.add(this);
16
+ }
17
+
18
+ postMessage(message) {
19
+ for (const channel of channels) {
20
+ if (channel !== this && channel.name === this.name && channel.onmessage) {
21
+ channel.onmessage({ data: message });
22
+ }
23
+ }
24
+ }
25
+
26
+ close() {
27
+ channels.delete(this);
28
+ }
29
+ }
30
+
31
+ class MockWebSocket {
32
+ static OPEN = 1;
33
+
34
+ constructor() {
35
+ this.OPEN = 1;
36
+ this.readyState = 1;
37
+ setTimeout(() => this.onopen?.(), 0);
38
+ }
39
+
40
+ send() {}
41
+
42
+ close() {}
43
+ }
44
+
45
+ function installBrowserMocks() {
46
+ channels.clear();
47
+ storageListeners.clear();
48
+ storageData.clear();
49
+
50
+ Object.defineProperty(globalThis, 'navigator', {
51
+ configurable: true,
52
+ value: {},
53
+ });
54
+ globalThis.BroadcastChannel = MockBroadcastChannel;
55
+ globalThis.WebSocket = MockWebSocket;
56
+ globalThis.window = {
57
+ addEventListener: (type, listener) => {
58
+ if (type === 'storage') storageListeners.add(listener);
59
+ },
60
+ removeEventListener: (type, listener) => {
61
+ if (type === 'storage') storageListeners.delete(listener);
62
+ },
63
+ };
64
+ globalThis.localStorage = {
65
+ getItem: (key) => storageData.get(key) ?? null,
66
+ setItem: (key, value) => {
67
+ const oldValue = storageData.get(key) ?? null;
68
+ storageData.set(key, value);
69
+ for (const listener of storageListeners) {
70
+ listener({ key, oldValue, newValue: value });
71
+ }
72
+ },
73
+ removeItem: (key) => storageData.delete(key),
74
+ };
75
+ }
76
+
77
+ function createSdk(ChatSDK) {
78
+ const sdk = new ChatSDK({
79
+ httpHost: 'https://example.test',
80
+ websocketHost: 'wss://example.test',
81
+ });
82
+
83
+ sdk.httpClient.get = async () => ({
84
+ ok: true,
85
+ data: null,
86
+ });
87
+
88
+ return sdk;
89
+ }
90
+
91
+ async function patchDistTestImports(dirUrl) {
92
+ const entries = await readdir(dirUrl, { withFileTypes: true });
93
+
94
+ await Promise.all(
95
+ entries.map(async (entry) => {
96
+ const entryUrl = new URL(
97
+ `${entry.name}${entry.isDirectory() ? '/' : ''}`,
98
+ dirUrl,
99
+ );
100
+
101
+ if (entry.isDirectory()) {
102
+ await patchDistTestImports(entryUrl);
103
+ return;
104
+ }
105
+
106
+ if (!entry.name.endsWith('.js')) {
107
+ return;
108
+ }
109
+
110
+ const source = await readFile(entryUrl, 'utf8');
111
+ const patched = await patchRelativeImports(source, entryUrl);
112
+
113
+ if (patched !== source) {
114
+ await writeFile(entryUrl, patched);
115
+ }
116
+ }),
117
+ );
118
+ }
119
+
120
+ async function pathExists(path) {
121
+ try {
122
+ await access(path);
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ async function patchRelativeImports(source, sourceUrl) {
130
+ const sourcePath = fileURLToPath(sourceUrl);
131
+ const sourceDir = dirname(sourcePath);
132
+ const importRegex =
133
+ /(?<prefix>from\s+['"])(?<specifier>\.\.?\/[^'"]+)(?<suffix>['"])/g;
134
+ const matches = [...source.matchAll(importRegex)];
135
+ let patched = source;
136
+
137
+ for (const match of matches) {
138
+ const { prefix, specifier, suffix } = match.groups;
139
+
140
+ if (specifier.endsWith('.js')) {
141
+ continue;
142
+ }
143
+
144
+ const targetPath = resolve(sourceDir, specifier);
145
+ let nextSpecifier = specifier;
146
+
147
+ if (await pathExists(`${targetPath}.js`)) {
148
+ nextSpecifier = `${specifier}.js`;
149
+ } else if (await pathExists(resolve(targetPath, 'index.js'))) {
150
+ nextSpecifier = `${specifier}/index.js`;
151
+ }
152
+
153
+ if (nextSpecifier !== specifier) {
154
+ patched = patched.replaceAll(
155
+ `${prefix}${specifier}${suffix}`,
156
+ `${prefix}${nextSpecifier}${suffix}`,
157
+ );
158
+ }
159
+ }
160
+
161
+ return patched;
162
+ }
163
+
164
+ test('new tab restores neighbor session without login API and without kicking neighbor', async () => {
165
+ installBrowserMocks();
166
+
167
+ await patchDistTestImports(new URL('../dist-test/', import.meta.url));
168
+ const { default: ChatSDK } = await import('../dist-test/modules/ChatSDK.js');
169
+
170
+ const oldSdk = createSdk(ChatSDK);
171
+ oldSdk.context.deviceId = 'device-1';
172
+ oldSdk.context.isLogin = true;
173
+ oldSdk.context.isReady = true;
174
+ oldSdk.context.loginUser = {
175
+ userId: 'user-1',
176
+ secretKey: 'secret-1',
177
+ sessionId: 'session-1',
178
+ };
179
+
180
+ let sessionLoginApiCalled = false;
181
+ const newSdk = createSdk(ChatSDK);
182
+ newSdk.httpClient.post = async (url) => {
183
+ if (url === '/user/session/login') {
184
+ sessionLoginApiCalled = true;
185
+ }
186
+ return {
187
+ ok: false,
188
+ code: -1,
189
+ msg: 'unexpected post',
190
+ };
191
+ };
192
+
193
+ try {
194
+ // Wait for auto-restore scheduled in init.
195
+ await new Promise((resolve) => setTimeout(resolve, 20));
196
+
197
+ assert.equal(newSdk.context.isLogin, true);
198
+ assert.equal(newSdk.context.isReady, true);
199
+ assert.equal(sessionLoginApiCalled, false);
200
+ assert.deepEqual(newSdk.context.loginUser, {
201
+ userId: 'user-1',
202
+ secretKey: 'secret-1',
203
+ sessionId: 'session-1',
204
+ });
205
+ assert.equal(oldSdk.context.isLogin, true);
206
+ assert.equal(oldSdk.context.isReady, true);
207
+
208
+ const loginRes = await newSdk.login({ user_code: 'session-1' });
209
+ assert.equal(loginRes.ok, true);
210
+ assert.equal(sessionLoginApiCalled, false);
211
+ } finally {
212
+ oldSdk.afterLogout();
213
+ newSdk.afterLogout();
214
+ oldSdk.tabSessionCoordinator.destroy();
215
+ newSdk.tabSessionCoordinator.destroy();
216
+ }
217
+ });
218
+
219
+ test('login on one tab notifies sibling tabs without calling login API again', async () => {
220
+ installBrowserMocks();
221
+
222
+ await patchDistTestImports(new URL('../dist-test/', import.meta.url));
223
+ const { default: ChatSDK } = await import('../dist-test/modules/ChatSDK.js');
224
+
225
+ const waitingSdk = createSdk(ChatSDK);
226
+ await new Promise((resolve) => setTimeout(resolve, 20));
227
+ assert.equal(waitingSdk.context.isLogin, false);
228
+
229
+ const loggingInSdk = createSdk(ChatSDK);
230
+ await new Promise((resolve) => setTimeout(resolve, 20));
231
+
232
+ let sessionLoginCalls = 0;
233
+ loggingInSdk.httpClient.post = async (url) => {
234
+ if (url === '/device_api/device_id') {
235
+ return { ok: true, data: 'device-shared' };
236
+ }
237
+ if (url === '/user/session/login') {
238
+ sessionLoginCalls += 1;
239
+ return {
240
+ ok: true,
241
+ data: {
242
+ id: 'user-1',
243
+ key: 'secret-1',
244
+ },
245
+ };
246
+ }
247
+ return { ok: false, code: -1, msg: `unexpected post ${url}` };
248
+ };
249
+ waitingSdk.httpClient.post = async (url) => {
250
+ if (url === '/user/session/login') {
251
+ sessionLoginCalls += 1;
252
+ }
253
+ return { ok: false, code: -1, msg: `unexpected post ${url}` };
254
+ };
255
+
256
+ try {
257
+ const res = await loggingInSdk.login({ user_code: 'session-1' });
258
+ await new Promise((resolve) => setTimeout(resolve, 0));
259
+
260
+ assert.equal(res.ok, true);
261
+ assert.equal(sessionLoginCalls, 1);
262
+ assert.equal(loggingInSdk.context.isLogin, true);
263
+ assert.equal(waitingSdk.context.isLogin, true);
264
+ assert.equal(waitingSdk.context.isReady, true);
265
+ assert.deepEqual(waitingSdk.context.loginUser, {
266
+ userId: 'user-1',
267
+ secretKey: 'secret-1',
268
+ sessionId: 'session-1',
269
+ });
270
+ } finally {
271
+ loggingInSdk.afterLogout();
272
+ waitingSdk.afterLogout();
273
+ loggingInSdk.tabSessionCoordinator.destroy();
274
+ waitingSdk.tabSessionCoordinator.destroy();
275
+ }
276
+ });
277
+
278
+ test('logout on one tab clears login state on sibling tabs without calling logout API again', async () => {
279
+ installBrowserMocks();
280
+
281
+ await patchDistTestImports(new URL('../dist-test/', import.meta.url));
282
+ const { default: ChatSDK } = await import('../dist-test/modules/ChatSDK.js');
283
+
284
+ const firstSdk = createSdk(ChatSDK);
285
+ firstSdk.context.deviceId = 'device-1';
286
+ firstSdk.context.isLogin = true;
287
+ firstSdk.context.isReady = true;
288
+ firstSdk.context.loginUser = {
289
+ userId: 'user-1',
290
+ secretKey: 'secret-1',
291
+ sessionId: 'session-1',
292
+ };
293
+
294
+ const secondSdk = createSdk(ChatSDK);
295
+ await new Promise((resolve) => setTimeout(resolve, 20));
296
+
297
+ assert.equal(secondSdk.context.isLogin, true);
298
+
299
+ let logoutApiCalls = 0;
300
+ firstSdk.httpClient.get = async (url) => {
301
+ if (url === '/user/logout') {
302
+ logoutApiCalls += 1;
303
+ return { ok: true, data: null };
304
+ }
305
+ return { ok: true, data: null };
306
+ };
307
+ secondSdk.httpClient.get = async (url) => {
308
+ if (url === '/user/logout') {
309
+ logoutApiCalls += 1;
310
+ return { ok: true, data: null };
311
+ }
312
+ return { ok: true, data: null };
313
+ };
314
+
315
+ try {
316
+ const res = await firstSdk.logout();
317
+ await new Promise((resolve) => setTimeout(resolve, 0));
318
+
319
+ assert.equal(res.ok, true);
320
+ assert.equal(logoutApiCalls, 1);
321
+ assert.equal(firstSdk.context.isLogin, false);
322
+ assert.equal(firstSdk.context.isReady, false);
323
+ assert.equal(secondSdk.context.isLogin, false);
324
+ assert.equal(secondSdk.context.isReady, false);
325
+ } finally {
326
+ firstSdk.afterLogout();
327
+ secondSdk.afterLogout();
328
+ firstSdk.tabSessionCoordinator.destroy();
329
+ secondSdk.tabSessionCoordinator.destroy();
330
+ }
331
+ });
@@ -53,54 +53,6 @@ function installBrowserMocks() {
53
53
  };
54
54
  }
55
55
 
56
- test('a new tab takeover request only notifies the old tab', async () => {
57
- installBrowserMocks();
58
-
59
- let oldTabKicked = 0;
60
- let newTabKicked = 0;
61
- const oldTab = new TabSessionCoordinator({
62
- onTakeoverRequest: () => {
63
- oldTabKicked += 1;
64
- },
65
- });
66
- const newTab = new TabSessionCoordinator({
67
- onTakeoverRequest: () => {
68
- newTabKicked += 1;
69
- },
70
- });
71
-
72
- newTab.requestTakeover();
73
- await new Promise((resolve) => setTimeout(resolve, 0));
74
-
75
- assert.equal(oldTabKicked, 1);
76
- assert.equal(newTabKicked, 0);
77
-
78
- oldTab.destroy();
79
- newTab.destroy();
80
- });
81
-
82
- test('destroyed coordinators ignore later takeover requests', async () => {
83
- installBrowserMocks();
84
-
85
- let oldTabKicked = 0;
86
- const oldTab = new TabSessionCoordinator({
87
- onTakeoverRequest: () => {
88
- oldTabKicked += 1;
89
- },
90
- });
91
- const newTab = new TabSessionCoordinator({
92
- onTakeoverRequest: () => {},
93
- });
94
-
95
- oldTab.destroy();
96
- newTab.requestTakeover();
97
- await new Promise((resolve) => setTimeout(resolve, 0));
98
-
99
- assert.equal(oldTabKicked, 0);
100
-
101
- newTab.destroy();
102
- });
103
-
104
56
  test('a new tab can request the current login session from an old tab', async () => {
105
57
  installBrowserMocks();
106
58
 
@@ -114,11 +66,8 @@ test('a new tab can request the current login session from an old tab', async ()
114
66
  };
115
67
  const oldTab = new TabSessionCoordinator({
116
68
  getCurrentSession: () => session,
117
- onTakeoverRequest: () => {},
118
- });
119
- const newTab = new TabSessionCoordinator({
120
- onTakeoverRequest: () => {},
121
69
  });
70
+ const newTab = new TabSessionCoordinator({});
122
71
 
123
72
  const restoredSession = await newTab.requestCurrentSession(50);
124
73
 
@@ -141,11 +90,8 @@ test('login session responses are not persisted through localStorage', async ()
141
90
  };
142
91
  const oldTab = new TabSessionCoordinator({
143
92
  getCurrentSession: () => session,
144
- onTakeoverRequest: () => {},
145
- });
146
- const newTab = new TabSessionCoordinator({
147
- onTakeoverRequest: () => {},
148
93
  });
94
+ const newTab = new TabSessionCoordinator({});
149
95
 
150
96
  const restoredSession = await newTab.requestCurrentSession(50);
151
97
  const storedMessages = [...storageData.values()].map((value) =>
@@ -185,11 +131,8 @@ test('duplicate session requests from fallback channels are answered once', asyn
185
131
  sessionRequestCount += 1;
186
132
  return session;
187
133
  },
188
- onTakeoverRequest: () => {},
189
- });
190
- const newTab = new TabSessionCoordinator({
191
- onTakeoverRequest: () => {},
192
134
  });
135
+ const newTab = new TabSessionCoordinator({});
193
136
 
194
137
  await newTab.requestCurrentSession(50);
195
138
 
@@ -204,11 +147,8 @@ test('requesting a login session returns null when no tab is logged in', async (
204
147
 
205
148
  const oldTab = new TabSessionCoordinator({
206
149
  getCurrentSession: () => null,
207
- onTakeoverRequest: () => {},
208
- });
209
- const newTab = new TabSessionCoordinator({
210
- onTakeoverRequest: () => {},
211
150
  });
151
+ const newTab = new TabSessionCoordinator({});
212
152
 
213
153
  const restoredSession = await newTab.requestCurrentSession(1);
214
154
 
@@ -217,3 +157,64 @@ test('requesting a login session returns null when no tab is logged in', async (
217
157
  oldTab.destroy();
218
158
  newTab.destroy();
219
159
  });
160
+
161
+ test('logout broadcast notifies other tabs once', async () => {
162
+ installBrowserMocks();
163
+
164
+ let oldTabLogout = 0;
165
+ let newTabLogout = 0;
166
+ const oldTab = new TabSessionCoordinator({
167
+ onLogoutBroadcast: () => {
168
+ oldTabLogout += 1;
169
+ },
170
+ });
171
+ const newTab = new TabSessionCoordinator({
172
+ onLogoutBroadcast: () => {
173
+ newTabLogout += 1;
174
+ },
175
+ });
176
+
177
+ newTab.publishLogout();
178
+ await new Promise((resolve) => setTimeout(resolve, 0));
179
+
180
+ assert.equal(oldTabLogout, 1);
181
+ assert.equal(newTabLogout, 0);
182
+
183
+ oldTab.destroy();
184
+ newTab.destroy();
185
+ });
186
+
187
+ test('login broadcast notifies other tabs with session and does not persist secrets', async () => {
188
+ installBrowserMocks();
189
+
190
+ const session = {
191
+ deviceId: 'device-1',
192
+ loginUser: {
193
+ userId: 'user-1',
194
+ secretKey: 'secret-1',
195
+ sessionId: 'session-1',
196
+ },
197
+ };
198
+ /** @type {typeof session | null} */
199
+ let received = null;
200
+ const waitingTab = new TabSessionCoordinator({
201
+ onLoginBroadcast: (nextSession) => {
202
+ received = nextSession;
203
+ },
204
+ });
205
+ const loggingInTab = new TabSessionCoordinator({});
206
+
207
+ loggingInTab.publishLogin(session);
208
+ await new Promise((resolve) => setTimeout(resolve, 0));
209
+
210
+ assert.deepEqual(received, session);
211
+ assert.equal(
212
+ [...storageData.values()].some((value) =>
213
+ JSON.stringify(value).includes('secret-1'),
214
+ ),
215
+ false,
216
+ );
217
+
218
+ waitingTab.destroy();
219
+ loggingInTab.destroy();
220
+ });
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+
4
+ import { buildWebSocketConnectUrl } from '../dist-test/utils/websocket.js';
5
+
6
+ test('buildWebSocketConnectUrl appends platform and deviceId-based ws_route', () => {
7
+ const url = buildWebSocketConnectUrl('wss://w-tx.typex-test.cn/ws', {
8
+ platform: 'web',
9
+ wsRoute: 'device-abc',
10
+ });
11
+
12
+ const parsed = new URL(url);
13
+ assert.equal(parsed.origin + parsed.pathname, 'wss://w-tx.typex-test.cn/ws');
14
+ assert.equal(parsed.searchParams.get('platform'), 'web');
15
+ assert.equal(parsed.searchParams.get('ws_route'), 'device-abc');
16
+ });
17
+
18
+ test('buildWebSocketConnectUrl uses empty ws_route when deviceId is missing', () => {
19
+ const url = buildWebSocketConnectUrl('wss://w-tx.typex-test.cn/ws', {
20
+ wsRoute: null,
21
+ });
22
+
23
+ assert.equal(new URL(url).searchParams.get('ws_route'), '');
24
+ });
@@ -1,12 +0,0 @@
1
- export type SingletonLockRequestOptions = {
2
- timeoutMs?: number;
3
- retryIntervalMs?: number;
4
- };
5
- export default class SingletonLock {
6
- private obtained;
7
- private abortLockObtain;
8
- constructor();
9
- requestLock(options?: SingletonLockRequestOptions): Promise<boolean>;
10
- private requestLockOnce;
11
- releaseLock(): void;
12
- }