Compare commits

...

29 Commits

Author SHA1 Message Date
hainenber
d6c0276326 fix: resync lockfile on optional dep due to npm bug
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-05-22 21:54:50 +07:00
hainenber
3ef1d13f81 Merge branch 'master' into feat/migrate-to-vitest-for-superset-websocket
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-05-22 21:34:13 +07:00
hainenber
482235c296 Merge branch 'master' into feat/migrate-to-vitest-for-superset-websocket
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-05-12 22:05:39 +07:00
hainenber
c2f7812559 fix: restore lockfile with Vitest-related native binding
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-04-30 21:21:20 +07:00
hainenber
376a810706 chore: use single spy instead of duplicate
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-04-30 21:17:24 +07:00
hainenber
b6268963e2 Merge branch 'master' into feat/migrate-to-vitest-for-superset-websocket
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-04-30 21:13:45 +07:00
hainenber
c7fe52309b chore: re-sync lockfile
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-04-30 21:11:46 +07:00
hainenber
b85c8476e2 Merge branch 'master' into feat/migrate-to-vitest-for-superset-websocket
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-04-07 22:41:26 +07:00
hainenber
a1ce6696da chore: sync lockfile
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-04-07 22:40:22 +07:00
hainenber
3600cc3881 Merge branch 'master' into feat/migrate-to-vitest-for-superset-websocket
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-04-03 10:15:20 +07:00
hainenber
82ab6fe7b7 Merge branch 'master' into feat/migrate-to-vitest-for-superset-websocket
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-03-22 11:21:42 +07:00
Đỗ Trọng Hải
2ffa01fd5f Merge branch 'master' into feat/migrate-to-vitest-for-superset-websocket 2026-03-19 22:43:38 +07:00
hainenber
3a560d268c chore: fix lockfile sheenanigan
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-03-17 08:13:00 +07:00
hainenber
a4e0a73e93 Merge branch 'master' into feat/migrate-to-vitest-for-superset-websocket
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-03-17 08:03:42 +07:00
Đỗ Trọng Hải
6142fb6e66 Merge branch 'master' into feat/migrate-to-vitest-for-superset-websocket 2026-03-10 16:31:13 +07:00
hainenber
89c138a8cb feat(embedded): migrate test tooling from Jest to Vitest
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-03-10 16:29:36 +07:00
hainenber
bf0ab39417 chore: resolve prettier issue
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-03-05 21:40:39 +07:00
hainenber
5f582b2f01 Merge branch 'master' into feat/migrate-to-vitest-for-superset-websocket
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-03-05 09:58:19 +07:00
hainenber
e9a0367bb4 fix: ensure the mocked redis.xrange is not called for test assertion
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-03-05 09:57:43 +07:00
hainenber
cf7261c142 chore: replace workaround with permanent spyOn for static getter
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-03-05 09:54:10 +07:00
hainenber
8f647e7cc9 chore(ci): setup Node version based on superset-websocket's .nvmrc
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-28 17:01:41 +07:00
hainenber
a4ea16416e chore: sync lockfile
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-28 17:01:14 +07:00
hainenber
a3c3e91ec3 chore: fix borked lockfile
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-28 16:22:39 +07:00
hainenber
081a5dd442 chore: update lockfile to v3
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-28 16:18:10 +07:00
hainenber
25b3ab7f89 Merge branch 'master' into feat/migrate-to-vitest-for-superset-websocket
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-28 16:13:11 +07:00
Đỗ Trọng Hải
02fb3c6d2d Apply suggestions from code review
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
2026-02-28 16:07:41 +07:00
hainenber
454dff9dd0 chore: prettify added lines of code
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-28 16:01:24 +07:00
hainenber
be9a136a37 chore: sync lockfile
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-28 15:58:33 +07:00
hainenber
11d476ed8d feat(ws/dev-dep): replace Jest with modern Vitest
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-28 15:53:47 +07:00
11 changed files with 2870 additions and 19046 deletions

View File

@@ -27,6 +27,10 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: './superset-websocket/.nvmrc'
- name: Install dependencies
working-directory: ./superset-websocket
run: npm ci

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@
"scripts": {
"build": "tsc && babel src --out-dir lib --extensions '.ts,.tsx' && webpack --mode production",
"ci:release": "node ./release-if-necessary.js",
"test": "jest"
"test": "vitest --run"
},
"browserslist": [
"last 3 chrome versions",
@@ -41,12 +41,11 @@
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.24.7",
"@types/jest": "^29.5.12",
"@types/node": "^22.5.4",
"@types/node": "^25.4.0",
"babel-loader": "^9.1.3",
"jest": "^29.7.0",
"tscw-config": "^1.1.2",
"typescript": "^5.6.2",
"typescript": "^5.9.3",
"vitest": "^4.0.18",
"webpack": "^5.94.0",
"webpack-cli": "^5.1.4"
},

View File

@@ -23,16 +23,17 @@ import {
MIN_REFRESH_WAIT_MS,
DEFAULT_TOKEN_EXP_MS,
} from "./guestTokenRefresh";
import { afterAll, beforeAll, it, expect, describe, vi } from 'vitest';
describe("guest token refresh", () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2022-03-03 01:00"));
jest.spyOn(global, "setTimeout");
vi.useFakeTimers();
vi.setSystemTime(new Date("2022-03-03 01:00"));
vi.spyOn(global, "setTimeout");
});
afterAll(() => {
jest.useRealTimers();
vi.useRealTimers();
});
function makeFakeJWT(claims: any) {

View File

@@ -3,7 +3,7 @@
// syntax rules
"strict": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
// environment
"target": "es6",
@@ -21,7 +21,6 @@
],
"exclude": [
"tests",
"dist",
"lib",
"node_modules"

View File

@@ -1,22 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
"main": "index.js",
"scripts": {
"start": "node dist/index.js start",
"test": "NODE_ENV=test jest -i spec",
"test": "npx vitest --run",
"type": "tsc --noEmit",
"eslint": "eslint",
"lint": "npm run eslint -- . && npm run type",
@@ -28,7 +28,6 @@
"devDependencies": {
"@eslint/js": "^9.25.1",
"@types/eslint__js": "^8.42.3",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.24",
"@types/node": "^25.9.1",
@@ -39,13 +38,12 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
"globals": "^17.6.0",
"jest": "^29.7.0",
"prettier": "^3.8.3",
"ts-jest": "^29.4.10",
"ts-node": "^10.9.2",
"tscw-config": "^1.1.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.59.4"
"typescript-eslint": "^8.59.4",
"vitest": "^4.1.5"
},
"engines": {
"node": "^22.22.0",

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { buildConfig } from '../src/config';
import { expect, test } from '@jest/globals';
import { expect, test } from 'vitest';
test('buildConfig() builds configuration and applies env var overrides', () => {
let config = buildConfig();

View File

@@ -25,29 +25,29 @@ import {
test,
beforeEach,
afterEach,
jest,
} from '@jest/globals';
vi,
type Mock,
} from 'vitest';
import * as http from 'http';
import * as net from 'net';
import { WebSocket } from 'ws';
import * as server from '../src/index';
import { statsd } from '../src/index';
interface MockedRedisXrange {
(): Promise<server.StreamResult[]>;
}
// NOTE: these mock variables needs to start with "mock" due to
// calls to `jest.mock` being hoisted to the top of the file.
// https://jestjs.io/docs/es6-class-mocks#calling-jestmock-with-the-module-factory-parameter
const mockRedisXrange = jest.fn() as jest.MockedFunction<MockedRedisXrange>;
jest.mock('ws');
jest.mock('ioredis', () => {
return jest.fn().mockImplementation(() => {
return { xrange: mockRedisXrange };
});
const { mockRedisXrange } = vi.hoisted(() => {
return { mockRedisXrange: vi.fn() };
});
const wsMock = WebSocket as jest.Mocked<typeof WebSocket>;
vi.mock('ws');
vi.mock('ioredis', () => {
return {
Redis: vi.fn().mockImplementation(function () {
return { xrange: mockRedisXrange };
}),
};
});
const wsMock = WebSocket as unknown as Mock<typeof WebSocket>;
const channelId = 'bc9e040c-7b4a-4817-99b9-292832d97ec7';
const streamReturnValue: server.StreamResult[] = [
[
@@ -66,16 +66,13 @@ const streamReturnValue: server.StreamResult[] = [
],
];
import * as server from '../src/index';
import { statsd } from '../src/index';
describe('server', () => {
let statsdIncrementMock: jest.SpiedFunction<typeof statsd.increment>;
let statsdIncrementMock: Mock<typeof statsd.increment>;
beforeEach(() => {
mockRedisXrange.mockClear();
server.resetState();
statsdIncrementMock = jest.spyOn(statsd, 'increment').mockReturnValue();
statsdIncrementMock = vi.spyOn(statsd, 'increment').mockReturnValue();
});
afterEach(() => {
@@ -84,8 +81,8 @@ describe('server', () => {
describe('HTTP requests', () => {
test('services health checks', () => {
const endMock = jest.fn();
const writeHeadMock = jest.fn();
const endMock = vi.fn();
const writeHeadMock = vi.fn();
const request = {
url: '/health',
@@ -113,8 +110,8 @@ describe('server', () => {
});
test('responds with a 404 when not found', () => {
const endMock = jest.fn();
const writeHeadMock = jest.fn();
const endMock = vi.fn();
const writeHeadMock = vi.fn();
const request = {
url: '/unsupported',
@@ -208,7 +205,7 @@ describe('server', () => {
describe('processStreamResults', () => {
test('sends data to channel', async () => {
const ws = new wsMock('localhost');
const sendMock = jest.spyOn(ws, 'send');
const sendMock = vi.spyOn(ws, 'send');
const socketInstance = { ws: ws, channel: channelId, pongTs: Date.now() };
expect(statsdIncrementMock).toHaveBeenCalledTimes(0);
@@ -230,7 +227,7 @@ describe('server', () => {
test('channel not present', async () => {
const ws = new wsMock('localhost');
const sendMock = jest.spyOn(ws, 'send');
const sendMock = vi.spyOn(ws, 'send');
expect(statsdIncrementMock).toHaveBeenCalledTimes(0);
server.processStreamResults(streamReturnValue);
@@ -241,10 +238,9 @@ describe('server', () => {
test('error sending data to client', async () => {
const ws = new wsMock('localhost');
const sendMock = jest.spyOn(ws, 'send').mockImplementation(() => {
const sendMock = vi.spyOn(ws, 'send').mockImplementation(() => {
throw new Error();
});
const cleanChannelMock = jest.spyOn(server, 'cleanChannel');
const socketInstance = { ws: ws, channel: channelId, pongTs: Date.now() };
expect(statsdIncrementMock).toHaveBeenCalledTimes(0);
@@ -263,9 +259,7 @@ describe('server', () => {
);
expect(sendMock).toHaveBeenCalled();
expect(cleanChannelMock).toHaveBeenCalledWith(channelId);
cleanChannelMock.mockRestore();
expect(Object.keys(server.channels)).toHaveLength(0);
});
});
@@ -276,7 +270,7 @@ describe('server', () => {
test('success with results', async () => {
mockRedisXrange.mockResolvedValueOnce(streamReturnValue);
const cb = jest.fn();
const cb = vi.fn();
await server.fetchRangeFromStream({
sessionId: '123',
startId: '-',
@@ -293,7 +287,7 @@ describe('server', () => {
});
test('success no results', async () => {
const cb = jest.fn();
const cb = vi.fn();
await server.fetchRangeFromStream({
sessionId: '123',
startId: '-',
@@ -310,7 +304,7 @@ describe('server', () => {
});
test('error', async () => {
const cb = jest.fn();
const cb = vi.fn();
mockRedisXrange.mockRejectedValueOnce(new Error());
await server.fetchRangeFromStream({
sessionId: '123',
@@ -330,12 +324,8 @@ describe('server', () => {
describe('wsConnection', () => {
let ws: WebSocket;
let wsEventMock: jest.SpiedFunction<typeof ws.on>;
let trackClientSpy: jest.SpiedFunction<typeof server.trackClient>;
let fetchRangeFromStreamSpy: jest.SpiedFunction<
typeof server.fetchRangeFromStream
>;
let dateNowSpy: jest.SpiedFunction<typeof Date.now>;
let wsEventMock: Mock<typeof ws.on>;
let dateNowSpy: Mock<typeof Date.now>;
let socketInstanceExpected: server.SocketInstance;
const getRequest = (token: string, url: string): http.IncomingMessage => {
@@ -348,10 +338,8 @@ describe('server', () => {
beforeEach(() => {
ws = new wsMock('localhost');
wsEventMock = jest.spyOn(ws, 'on');
trackClientSpy = jest.spyOn(server, 'trackClient');
fetchRangeFromStreamSpy = jest.spyOn(server, 'fetchRangeFromStream');
dateNowSpy = jest
wsEventMock = vi.spyOn(ws, 'on');
dateNowSpy = vi
.spyOn(global.Date, 'now')
.mockImplementation(() =>
new Date('2021-03-10T11:01:58.135Z').valueOf(),
@@ -365,8 +353,6 @@ describe('server', () => {
afterEach(() => {
wsEventMock.mockRestore();
trackClientSpy.mockRestore();
fetchRangeFromStreamSpy.mockRestore();
dateNowSpy.mockRestore();
});
@@ -385,11 +371,14 @@ describe('server', () => {
server.wsConnection(ws, request);
expect(trackClientSpy).toHaveBeenCalledWith(
channelId,
socketInstanceExpected,
);
expect(fetchRangeFromStreamSpy).not.toHaveBeenCalled();
const channelSockets = server.channels[channelId];
expect(channelSockets).toEqual({
sockets: expect.any(Array<string>),
});
expect(channelSockets.sockets).toHaveLength(1);
const socketId = channelSockets.sockets[0];
expect(server.sockets[socketId]).toEqual(socketInstanceExpected);
expect(mockRedisXrange).not.toHaveBeenCalled();
expect(wsEventMock).toHaveBeenCalledWith('pong', expect.any(Function));
});
@@ -403,16 +392,18 @@ describe('server', () => {
server.wsConnection(ws, request);
expect(trackClientSpy).toHaveBeenCalledWith(
channelId,
socketInstanceExpected,
);
expect(fetchRangeFromStreamSpy).toHaveBeenCalledWith({
sessionId: channelId,
startId: '1615426152415-1',
endId: '+',
listener: server.processStreamResults,
const channelSockets = server.channels[channelId];
expect(channelSockets).toEqual({
sockets: expect.any(Array<string>),
});
expect(channelSockets.sockets).toHaveLength(1);
const socketId = channelSockets.sockets[0];
expect(server.sockets[socketId]).toEqual(socketInstanceExpected);
expect(mockRedisXrange).toHaveBeenCalledWith(
expect.stringContaining(channelId),
'1615426152415-1',
'+',
);
expect(wsEventMock).toHaveBeenCalledWith('pong', expect.any(Function));
});
@@ -428,24 +419,26 @@ describe('server', () => {
server.setLastFirehoseId(lastFirehoseId);
server.wsConnection(ws, request);
expect(trackClientSpy).toHaveBeenCalledWith(
channelId,
socketInstanceExpected,
);
expect(fetchRangeFromStreamSpy).toHaveBeenCalledWith({
sessionId: channelId,
startId: '1615426152415-1',
endId: lastFirehoseId,
listener: server.processStreamResults,
const channelSockets = server.channels[channelId];
expect(channelSockets).toEqual({
sockets: expect.any(Array<string>),
});
expect(channelSockets.sockets).toHaveLength(1);
const socketId = channelSockets.sockets[0];
expect(server.sockets[socketId]).toEqual(socketInstanceExpected);
expect(mockRedisXrange).toHaveBeenCalledWith(
expect.stringContaining(channelId),
'1615426152415-1',
lastFirehoseId,
);
expect(wsEventMock).toHaveBeenCalledWith('pong', expect.any(Function));
});
});
describe('httpUpgrade', () => {
let socket: net.Socket;
let socketDestroySpy: jest.SpiedFunction<typeof socket.destroy>;
let wssUpgradeSpy: jest.SpiedFunction<typeof server.wss.handleUpgrade>;
let socketDestroySpy: Mock<typeof socket.destroy>;
let wssUpgradeSpy: Mock<typeof server.wss.handleUpgrade>;
const getRequest = (token: string, url: string): http.IncomingMessage => {
const request = new http.IncomingMessage(new net.Socket());
@@ -457,8 +450,8 @@ describe('server', () => {
beforeEach(() => {
socket = new net.Socket();
socketDestroySpy = jest.spyOn(socket, 'destroy');
wssUpgradeSpy = jest.spyOn(server.wss, 'handleUpgrade');
socketDestroySpy = vi.spyOn(socket, 'destroy');
wssUpgradeSpy = vi.spyOn(server.wss, 'handleUpgrade');
});
afterEach(() => {
@@ -495,33 +488,21 @@ describe('server', () => {
});
});
const setReadyState = (ws: WebSocket, value: typeof ws.readyState) => {
// workaround for not being able to do
// spyOn(instance,'readyState','get').and.returnValue(value);
// See for details: https://github.com/facebook/jest/issues/9675
Object.defineProperty(ws, 'readyState', {
configurable: true,
get() {
return value;
},
});
};
describe('checkSockets', () => {
let ws: WebSocket;
let pingSpy: jest.SpiedFunction<typeof ws.ping>;
let terminateSpy: jest.SpiedFunction<typeof ws.terminate>;
let pingSpy: Mock<typeof ws.ping>;
let terminateSpy: Mock<typeof ws.terminate>;
let socketInstance: server.SocketInstance;
beforeEach(() => {
ws = new wsMock('localhost');
pingSpy = jest.spyOn(ws, 'ping');
terminateSpy = jest.spyOn(ws, 'terminate');
pingSpy = vi.spyOn(ws, 'ping');
terminateSpy = vi.spyOn(ws, 'terminate');
socketInstance = { ws: ws, channel: channelId, pongTs: Date.now() };
});
test('active sockets', () => {
setReadyState(ws, WebSocket.OPEN);
vi.spyOn(ws, 'readyState', 'get').mockReturnValue(WebSocket.OPEN);
server.trackClient(channelId, socketInstance);
server.checkSockets();
@@ -532,7 +513,7 @@ describe('server', () => {
});
test('stale sockets', () => {
setReadyState(ws, WebSocket.OPEN);
vi.spyOn(ws, 'readyState', 'get').mockReturnValue(WebSocket.OPEN);
socketInstance.pongTs = Date.now() - 60000;
server.trackClient(channelId, socketInstance);
@@ -544,7 +525,7 @@ describe('server', () => {
});
test('closed sockets', () => {
setReadyState(ws, WebSocket.CLOSED);
vi.spyOn(ws, 'readyState', 'get').mockReturnValue(WebSocket.CLOSED);
server.trackClient(channelId, socketInstance);
server.checkSockets();
@@ -570,7 +551,7 @@ describe('server', () => {
});
test('active sockets', () => {
setReadyState(ws, WebSocket.OPEN);
vi.spyOn(ws, 'readyState', 'get').mockReturnValue(WebSocket.OPEN);
server.trackClient(channelId, socketInstance);
server.cleanChannel(channelId);
@@ -579,7 +560,7 @@ describe('server', () => {
});
test('closing sockets', () => {
setReadyState(ws, WebSocket.CLOSING);
vi.spyOn(ws, 'readyState', 'get').mockReturnValue(WebSocket.CLOSING);
server.trackClient(channelId, socketInstance);
server.cleanChannel(channelId);
@@ -588,11 +569,12 @@ describe('server', () => {
});
test('multiple sockets', () => {
setReadyState(ws, WebSocket.OPEN);
vi.spyOn(ws, 'readyState', 'get').mockReturnValue(WebSocket.OPEN);
server.trackClient(channelId, socketInstance);
const ws2 = new wsMock('localhost');
setReadyState(ws2, WebSocket.OPEN);
const readyStateSpy = vi.spyOn(ws2, 'readyState', 'get');
readyStateSpy.mockReturnValue(WebSocket.OPEN);
const socketInstance2 = {
ws: ws2,
channel: channelId,
@@ -604,7 +586,7 @@ describe('server', () => {
expect(server.channels[channelId].sockets.length).toBe(2);
setReadyState(ws2, WebSocket.CLOSED);
readyStateSpy.mockReturnValue(WebSocket.CLOSED);
server.cleanChannel(channelId);
expect(server.channels[channelId].sockets.length).toBe(1);

View File

@@ -18,11 +18,11 @@
*/
import * as http from 'http';
import * as net from 'net';
import WebSocket from 'ws';
import { WebSocket, WebSocketServer } from 'ws';
import { randomUUID } from 'crypto';
import jwt, { Algorithm } from 'jsonwebtoken';
import { parse } from 'cookie';
import Redis, { RedisOptions } from 'ioredis';
import { Redis, RedisOptions } from 'ioredis';
import StatsD from 'hot-shots';
import { createLogger } from './logger';
@@ -141,7 +141,7 @@ export const buildRedisOpts = (baseConfig: RedisConfig) => {
// initialize servers
const redis = new Redis(buildRedisOpts(opts.redis));
const httpServer = http.createServer();
export const wss = new WebSocket.Server({
export const wss = new WebSocketServer({
noServer: true,
clientTracking: false,
});