@typexim/web-sdk 0.0.7 → 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.
- package/README.md +2 -1
- package/dist/types/enums/sdk.d.ts +0 -1
- package/dist/types/modules/ChatSDK.d.ts +3 -2
- package/dist/types/modules/ReConnectWebsocket.d.ts +3 -0
- package/dist/types/modules/TabSessionCoordinator.d.ts +9 -5
- package/dist/types/utils/websocket.d.ts +4 -0
- package/dist/web-sdk.esm.js +1 -1
- package/dist/web-sdk.js +1 -1
- package/dist-test/enums/business.js +139 -0
- package/dist-test/enums/index.js +2 -0
- package/dist-test/enums/sdk.js +24 -0
- package/dist-test/events.js +50 -0
- package/dist-test/index.js +26 -0
- package/dist-test/interfaces/business.js +1 -0
- package/dist-test/interfaces/index.js +3 -0
- package/dist-test/interfaces/pushMessage.js +1 -0
- package/dist-test/interfaces/sdk/api.js +1 -0
- package/dist-test/interfaces/sdk/common.js +1 -0
- package/dist-test/interfaces/sdk/index.js +2 -0
- package/dist-test/modules/Base.js +70 -0
- package/dist-test/modules/BusinessWebsocket.js +68 -0
- package/dist-test/modules/ChatSDK.js +698 -0
- package/dist-test/modules/FeedCache.js +211 -0
- package/dist-test/modules/HttpClient.js +104 -0
- package/dist-test/modules/Logger.js +73 -0
- package/dist-test/modules/PushMessageProcessor.js +437 -0
- package/dist-test/modules/ReConnectWebsocket.js +223 -0
- package/dist-test/modules/Storage.js +27 -0
- package/dist-test/modules/TabSessionCoordinator.js +289 -0
- package/dist-test/modules/Version.js +20 -0
- package/dist-test/utils/date.js +4 -0
- package/dist-test/utils/http.js +13 -0
- package/dist-test/utils/makeSign.js +79 -0
- package/dist-test/utils/message.js +146 -0
- package/dist-test/utils/misc.js +13 -0
- package/dist-test/utils/promise.js +10 -0
- package/dist-test/utils/sleep.js +3 -0
- package/dist-test/utils/websocket.js +24 -0
- package/package.json +1 -1
- package/test/{chat-sdk-restore-login.test.mjs → chat-sdk-independent-login.test.mjs} +50 -62
- package/test/chat-sdk-session-restore.test.mjs +331 -0
- package/test/tab-session-coordinator.test.mjs +65 -64
- package/test/websocket-url.test.mjs +24 -0
- package/dist/types/modules/SingletonLock.d.ts +0 -12
- 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: (
|
|
74
|
-
|
|
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('
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
assert.equal(
|
|
219
|
-
assert.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
assert.equal(
|
|
225
|
-
assert.equal(
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
}
|