Commit 2f44b02 for jssip.net

commit 2f44b02d57ec497b7bab38c3412cf845f819369c
Author: José Luis Millán <jmillan@aliax.net>
Date:   Tue Jan 27 16:02:26 2026 +0100

    Rewrite tests to TypeScript (#958)

diff --git a/.gitignore b/.gitignore
index 216bf0e..8d4f7a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,9 @@
+## Node.
 /node_modules
 /lib
 /dist
+
+## Others.
+/docs
+/coverage
+/.cache
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a29ac42..20c6828 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
   - Modernize eslint.
   - Use prettier.
   - Prepare environment for TS.
+- Rewrite tests to TS (#958).

 ### 3.12.0

diff --git a/jest.config.js b/jest.config.js
deleted file mode 100644
index ac9471a..0000000
--- a/jest.config.js
+++ /dev/null
@@ -1,3 +0,0 @@
-module.exports = {
-	testRegex: 'src/test/test-.*\\.js',
-};
diff --git a/jest.config.mjs b/jest.config.mjs
new file mode 100644
index 0000000..0a8d78b
--- /dev/null
+++ b/jest.config.mjs
@@ -0,0 +1,18 @@
+const config = {
+	verbose: true,
+	preset: 'ts-jest',
+	testEnvironment: 'node',
+	testRegex: 'src/test/test-.*\\.ts',
+	transform: {
+		'^.+\\.ts$': [
+			'ts-jest',
+			{
+				tsconfig: 'tsconfig.json',
+			},
+		],
+	},
+	coveragePathIgnorePatterns: ['src/test'],
+	cacheDirectory: '.cache/jest',
+};
+
+export default config;
diff --git a/npm-scripts.mjs b/npm-scripts.mjs
index 1a7fcfc..c47c08d 100644
--- a/npm-scripts.mjs
+++ b/npm-scripts.mjs
@@ -30,21 +30,31 @@ async function run() {
 	switch (task) {
 		case 'grammar': {
 			grammar();
+
 			break;
 		}

 		case 'lint': {
 			lint();
+
 			break;
 		}

 		case 'lint:fix': {
 			lint(true);
+
 			break;
 		}

 		case 'test': {
 			test();
+
+			break;
+		}
+
+		case 'coverage': {
+			coverage();
+
 			break;
 		}

@@ -72,6 +82,7 @@ async function run() {

 			// eslint-disable-next-line no-console
 			console.log('update tryit-jssip and JsSIP website');
+
 			break;
 		}

@@ -92,9 +103,14 @@ function lint(fix = false) {
 function test() {
 	logInfo('test()');

-	// TODO: remove when tests are written in TS.
-	buildTypescript();
-	executeCmd('jest');
+	executeCmd(`jest --silent false --detectOpenHandles ${taskArgs}`);
+}
+
+function coverage() {
+	logInfo('coverage()');
+
+	executeCmd(`jest --coverage ${taskArgs}`);
+	executeCmd('open-cli coverage/lcov-report/index.html');
 }

 function grammar() {
diff --git a/package.json b/package.json
index 603ef16..d8909bd 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
 		"lint": "node npm-scripts.mjs lint",
 		"lint:fix": "node npm-scripts.mjs lint:fix",
 		"test": "node npm-scripts.mjs test",
+		"coverage": "node npm-scripts.mjs coverage",
 		"build": "node npm-scripts.mjs build",
 		"typescript:build": "node npm-scripts.mjs typescript:build",
 		"release": "node npm-scripts.js release"
@@ -50,6 +51,7 @@
 		"@eslint/js": "^9.39.2",
 		"@types/debug": "^4.1.12",
 		"@types/events": "^3.0.3",
+		"@types/jest": "^30.0.0",
 		"@types/node": "^25.0.10",
 		"cpx": "^1.5.0",
 		"esbuild": "^0.27.2",
@@ -59,8 +61,10 @@
 		"eslint-plugin-prettier": "^5.5.5",
 		"globals": "^17.0.0",
 		"jest": "^30.2.0",
+		"open-cli": "^8.0.0",
 		"pegjs": "^0.7.0",
 		"prettier": "^3.8.1",
+		"ts-jest": "^29.4.6",
 		"typescript": "^5.9.3",
 		"typescript-eslint": "^8.53.1"
 	}
diff --git a/src/NameAddrHeader.d.ts b/src/NameAddrHeader.d.ts
index 59e15cb..c897ae4 100644
--- a/src/NameAddrHeader.d.ts
+++ b/src/NameAddrHeader.d.ts
@@ -9,7 +9,7 @@ export class NameAddrHeader {

 	constructor(uri: URI, display_name?: string, parameters?: Parameters);

-	setParam(key: string, value?: string): void;
+	setParam(key: string, value?: string | number | null): void;

 	// eslint-disable-next-line @typescript-eslint/no-explicit-any
 	getParam<T = any>(key: string): T;
diff --git a/src/URI.d.ts b/src/URI.d.ts
index 05fcd0d..eb1502c 100644
--- a/src/URI.d.ts
+++ b/src/URI.d.ts
@@ -21,7 +21,7 @@ export class URI {
 		headers?: Headers
 	);

-	setParam(key: string, value?: string): void;
+	setParam(key: string, value?: string | number | null): void;

 	getParam<T = unknown>(key: string): T;

@@ -45,7 +45,7 @@ export class URI {

 	toString(): string;

-	toAor(): string;
+	toAor(show_port?: boolean): string;

 	static parse(uri: string): Grammar | undefined;
 }
diff --git a/src/test/include/LoopSocket.ts b/src/test/include/LoopSocket.ts
new file mode 100644
index 0000000..0bc9121
--- /dev/null
+++ b/src/test/include/LoopSocket.ts
@@ -0,0 +1,58 @@
+// LoopSocket send message itself.
+// Used P2P logic: message call-id is modified in each leg.
+
+import type { Socket } from '../../Socket';
+
+export default class LoopSocket implements Socket {
+	url = 'ws://localhost:12345';
+	via_transport = 'WS';
+	sip_uri = 'sip:localhost:12345;transport=ws';
+
+	connect(): void {
+		setTimeout(() => {
+			this.onconnect();
+		}, 0);
+	}
+
+	disconnect(): void {}
+
+	send(message: string): boolean {
+		const new_message = this.modifyCallId(message);
+
+		setTimeout(() => {
+			this.ondata(new_message);
+		}, 0);
+
+		return true;
+	}
+
+	isConnected(): boolean {
+		return true;
+	}
+
+	isConnecting(): boolean {
+		return false;
+	}
+
+	onconnect(): void {}
+
+	ondisconnect(): void {}
+
+	// eslint-disable-next-line @typescript-eslint/no-unused-vars
+	ondata<T>(_event: T): void {}
+
+	// Call-ID: add or drop word '_second'.
+	private modifyCallId(message: string): string {
+		const begin = message.indexOf('Call-ID');
+		const end = message.indexOf('\r', begin);
+		let callId = message.substring(begin + 9, end);
+
+		if (callId.endsWith('_second')) {
+			callId = callId.substring(0, callId.length - 7);
+		} else {
+			callId += '_second';
+		}
+
+		return `${message.substring(0, begin)}Call-ID: ${callId}${message.substring(end)}`;
+	}
+}
diff --git a/src/test/include/common.js b/src/test/include/common.ts
similarity index 50%
rename from src/test/include/common.js
rename to src/test/include/common.ts
index 85a9094..2d57b49 100644
--- a/src/test/include/common.js
+++ b/src/test/include/common.ts
@@ -1,18 +1,20 @@
-/* eslint no-console: 0*/
+/* eslint no-console: 0 */

 // Show uncaught errors.
-process.on('uncaughtException', function (error) {
+process.on('uncaughtException', function (error: Error) {
 	console.error('uncaught exception:');
 	console.error(error.stack);
 	process.exit(1);
 });

 // Define global.WebSocket.
-global.WebSocket = function () {
+(globalThis as Record<string, unknown>)['WebSocket'] = function (this: {
+	close: () => void;
+}) {
 	this.close = function () {};
 };

 // Define global.navigator for bowser module.
-global.navigator = {
+(globalThis as Record<string, unknown>)['navigator'] = {
 	userAgent: '',
 };
diff --git a/src/test/include/consts.ts b/src/test/include/consts.ts
new file mode 100644
index 0000000..5e3a138
--- /dev/null
+++ b/src/test/include/consts.ts
@@ -0,0 +1,56 @@
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const SOCKET_DESCRIPTION: Record<string, any> = {
+	via_transport: 'WS',
+	sip_uri: 'sip:localhost:12345;transport=ws',
+	url: 'ws://localhost:12345',
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const UA_CONFIGURATION: Record<string, any> = {
+	uri: 'sip:f%61keUA@jssip.net',
+	password: '1234ññññ',
+	display_name: 'Fake UA ð→€ł !!!',
+	authorization_user: 'fakeUA',
+	instance_id: 'uuid:8f1fa16a-1165-4a96-8341-785b1ef24f12',
+	registrar_server: 'registrar.jssip.NET:6060;TRansport=TCP',
+	register_expires: 600,
+	register: false,
+	connection_recovery_min_interval: 2,
+	connection_recovery_max_interval: 30,
+	use_preloaded_route: true,
+	no_answer_timeout: 60000,
+	session_timers: true,
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const UA_CONFIGURATION_AFTER_START: Record<string, any> = {
+	uri: 'sip:fakeUA@jssip.net',
+	password: '1234ññññ',
+	display_name: 'Fake UA ð→€ł !!!',
+	authorization_user: 'fakeUA',
+	instance_id: '8f1fa16a-1165-4a96-8341-785b1ef24f12', // Without 'uuid:'.
+	registrar_server: 'sip:registrar.jssip.net:6060;transport=tcp',
+	register_expires: 600,
+	register: false,
+	use_preloaded_route: true,
+	no_answer_timeout: 60000 * 1000, // Internally converted to miliseconds.
+	session_timers: true,
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const UA_TRANSPORT_AFTER_START: Record<string, any> = {
+	sockets: [
+		{
+			socket: {
+				via_transport: 'WS',
+				sip_uri: 'sip:localhost:12345;transport=ws',
+				url: 'ws://localhost:12345',
+			},
+			weight: 0,
+		},
+	],
+	recovery_options: {
+		min_interval: 2,
+		max_interval: 30,
+	},
+};
diff --git a/src/test/include/loopSocket.js b/src/test/include/loopSocket.js
deleted file mode 100644
index c2a68dc..0000000
--- a/src/test/include/loopSocket.js
+++ /dev/null
@@ -1,42 +0,0 @@
-// LoopSocket send message itself.
-// Used P2P logic: message call-id is modified in each leg.
-module.exports = class LoopSocket {
-	constructor() {
-		this.url = 'ws://localhost:12345';
-		this.via_transport = 'WS';
-		this.sip_uri = 'sip:localhost:12345;transport=ws';
-	}
-
-	connect() {
-		setTimeout(() => {
-			this.onconnect();
-		}, 0);
-	}
-
-	disconnect() {}
-
-	send(message) {
-		const message2 = this._modifyCallId(message);
-
-		setTimeout(() => {
-			this.ondata(message2);
-		}, 0);
-
-		return true;
-	}
-
-	// Call-ID: add or drop word '_second'.
-	_modifyCallId(message) {
-		const ixBegin = message.indexOf('Call-ID');
-		const ixEnd = message.indexOf('\r', ixBegin);
-		let callId = message.substring(ixBegin + 9, ixEnd);
-
-		if (callId.endsWith('_second')) {
-			callId = callId.substring(0, callId.length - 7);
-		} else {
-			callId += '_second';
-		}
-
-		return `${message.substring(0, ixBegin)}Call-ID: ${callId}${message.substring(ixEnd)}`;
-	}
-};
diff --git a/src/test/include/testUA.js b/src/test/include/testUA.js
deleted file mode 100644
index 2e426a5..0000000
--- a/src/test/include/testUA.js
+++ /dev/null
@@ -1,54 +0,0 @@
-module.exports = {
-	SOCKET_DESCRIPTION: {
-		via_transport: 'WS',
-		sip_uri: 'sip:localhost:12345;transport=ws',
-		url: 'ws://localhost:12345',
-	},
-
-	UA_CONFIGURATION: {
-		uri: 'sip:f%61keUA@jssip.net',
-		password: '1234ññññ',
-		display_name: 'Fake UA ð→€ł !!!',
-		authorization_user: 'fakeUA',
-		instance_id: 'uuid:8f1fa16a-1165-4a96-8341-785b1ef24f12',
-		registrar_server: 'registrar.jssip.NET:6060;TRansport=TCP',
-		register_expires: 600,
-		register: false,
-		connection_recovery_min_interval: 2,
-		connection_recovery_max_interval: 30,
-		use_preloaded_route: true,
-		no_answer_timeout: 60000,
-		session_timers: true,
-	},
-
-	UA_CONFIGURATION_AFTER_START: {
-		uri: 'sip:fakeUA@jssip.net',
-		password: '1234ññññ',
-		display_name: 'Fake UA ð→€ł !!!',
-		authorization_user: 'fakeUA',
-		instance_id: '8f1fa16a-1165-4a96-8341-785b1ef24f12', // Without 'uuid:'.
-		registrar_server: 'sip:registrar.jssip.net:6060;transport=tcp',
-		register_expires: 600,
-		register: false,
-		use_preloaded_route: true,
-		no_answer_timeout: 60000 * 1000, // Internally converted to miliseconds.
-		session_timers: true,
-	},
-
-	UA_TRANSPORT_AFTER_START: {
-		sockets: [
-			{
-				socket: {
-					via_transport: 'WS',
-					sip_uri: 'sip:localhost:12345;transport=ws',
-					url: 'ws://localhost:12345',
-				},
-				weight: 0,
-			},
-		],
-		recovery_options: {
-			min_interval: 2,
-			max_interval: 30,
-		},
-	},
-};
diff --git a/src/test/test-UA-no-WebRTC.js b/src/test/test-UA-no-WebRTC.ts
similarity index 60%
rename from src/test/test-UA-no-WebRTC.js
rename to src/test/test-UA-no-WebRTC.ts
index 8c7c88d..a35f479 100644
--- a/src/test/test-UA-no-WebRTC.js
+++ b/src/test/test-UA-no-WebRTC.ts
@@ -1,27 +1,28 @@
 /* eslint no-console: 0*/

-require('./include/common');
-const testUA = require('./include/testUA');
-const JsSIP = require('../..');
+import './include/common';
+import * as consts from './include/consts';
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const JsSIP = require('../JsSIP.js');
+const { UA, WebSocketInterface, Exceptions, C } = JsSIP;

 describe('UA No WebRTC', () => {
 	test('UA wrong configuration', () => {
-		expect(() => new JsSIP.UA({ lalala: 'lololo' })).toThrow(
-			JsSIP.Exceptions.ConfigurationError
+		expect(() => new UA({ lalala: 'lololo' } as never)).toThrow(
+			Exceptions.ConfigurationError
 		);
 	});

 	test('UA no WS connection', () => {
-		const config = testUA.UA_CONFIGURATION;
-		const wsSocket = new JsSIP.WebSocketInterface(
-			testUA.SOCKET_DESCRIPTION.url
-		);
+		const config = consts.UA_CONFIGURATION;
+		const wsSocket = new WebSocketInterface(consts.SOCKET_DESCRIPTION['url']);

-		config.sockets = wsSocket;
+		config['sockets'] = wsSocket;

-		const ua = new JsSIP.UA(config);
+		const ua = new UA(config);

-		expect(ua instanceof JsSIP.UA).toBeTruthy();
+		expect(ua instanceof UA).toBeTruthy();

 		ua.start();

@@ -43,19 +44,19 @@ describe('UA No WebRTC', () => {
 			'<sip:anonymous@anonymous.invalid;transport=ws;ob>'
 		);

-		for (const parameter in testUA.UA_CONFIGURATION_AFTER_START) {
+		for (const parameter in consts.UA_CONFIGURATION_AFTER_START) {
 			if (
 				Object.prototype.hasOwnProperty.call(
-					testUA.UA_CONFIGURATION_AFTER_START,
+					consts.UA_CONFIGURATION_AFTER_START,
 					parameter
 				)
 			) {
 				switch (parameter) {
 					case 'uri':
 					case 'registrar_server': {
+						// eslint-disable-next-line jest/no-conditional-expect
 						expect(ua.configuration[parameter].toString()).toBe(
-							testUA.UA_CONFIGURATION_AFTER_START[parameter],
-							`testing parameter ${parameter}`
+							consts.UA_CONFIGURATION_AFTER_START[parameter]
 						);
 						break;
 					}
@@ -64,17 +65,17 @@ describe('UA No WebRTC', () => {
 						break;
 					}
 					default: {
+						// eslint-disable-next-line jest/no-conditional-expect
 						expect(ua.configuration[parameter]).toBe(
-							testUA.UA_CONFIGURATION_AFTER_START[parameter],
-							`testing parameter ${parameter}`
+							consts.UA_CONFIGURATION_AFTER_START[parameter]
 						);
 					}
 				}
 			}
 		}

-		const transport = testUA.UA_TRANSPORT_AFTER_START;
-		const sockets = transport.sockets;
+		const transport = consts.UA_TRANSPORT_AFTER_START;
+		const sockets = transport['sockets'];
 		const socket = sockets[0].socket;

 		expect(sockets.length).toEqual(ua.transport.sockets.length);
@@ -83,12 +84,15 @@ describe('UA No WebRTC', () => {
 		expect(socket.sip_uri).toEqual(ua.transport.sip_uri);
 		expect(socket.url).toEqual(ua.transport.url);

-		expect(transport.recovery_options).toEqual(ua.transport.recovery_options);
+		expect(transport['recovery_options']).toEqual(
+			ua.transport.recovery_options
+		);

 		ua.sendMessage('test', 'FAIL WITH CONNECTION_ERROR PLEASE', {
 			eventHandlers: {
-				failed: function (e) {
-					expect(e.cause).toEqual(JsSIP.C.causes.CONNECTION_ERROR);
+				// eslint-disable-next-line @typescript-eslint/no-explicit-any
+				failed: function (e: any) {
+					expect(e.cause).toEqual(C.causes.CONNECTION_ERROR);
 				},
 			},
 		});
diff --git a/src/test/test-UA-subscriber-notifier.js b/src/test/test-UA-subscriber-notifier.js
deleted file mode 100644
index 9c562d6..0000000
--- a/src/test/test-UA-subscriber-notifier.js
+++ /dev/null
@@ -1,175 +0,0 @@
-require('./include/common');
-const JsSIP = require('../..');
-const LoopSocket = require('./include/loopSocket');
-
-describe('subscriber/notifier communication', () => {
-	test('should handle subscriber/notifier communication', () =>
-		new Promise(resolve => {
-			let eventSequence = 0;
-
-			const TARGET = 'ikq';
-			const REQUEST_URI = 'sip:ikq@example.com';
-			const CONTACT_URI = 'sip:ikq@abcdefabcdef.invalid;transport=ws';
-			const SUBSCRIBE_ACCEPT = 'application/text, text/plain';
-			const EVENT_NAME = 'weather';
-			const CONTENT_TYPE = 'text/plain';
-			const WEATHER_REQUEST = 'Please report the weather condition';
-			const WEATHER_REPORT = '+20..+24°C, no precipitation, light wind';
-
-			/**
-			 * @param {JsSIP.UA} ua
-			 */
-			function createSubscriber(ua) {
-				const options = {
-					expires: 3600,
-					contentType: CONTENT_TYPE,
-					params: null,
-				};
-
-				const subscriber = ua.subscribe(
-					TARGET,
-					EVENT_NAME,
-					SUBSCRIBE_ACCEPT,
-					options
-				);
-
-				subscriber.on('active', () => {
-					// 'receive notify with subscription-state: active'
-					expect(++eventSequence).toBe(6);
-				});
-
-				subscriber.on('notify', (isFinal, notify, body, contType) => {
-					eventSequence++;
-					// 'receive notify'
-					expect(eventSequence === 7 || eventSequence === 11).toBe(true);
-
-					expect(notify.method).toBe('NOTIFY');
-					expect(notify.getHeader('contact')).toBe(`<${CONTACT_URI}>`); // 'notify contact'
-					expect(body).toBe(WEATHER_REPORT); // 'notify body'
-					expect(contType).toBe(CONTENT_TYPE); // 'notify content-type'
-
-					const subsState = notify.parseHeader('subscription-state').state;
-
-					expect(
-						subsState === 'pending' ||
-							subsState === 'active' ||
-							subsState === 'terminated'
-					).toBe(true); // 'notify subscription-state'
-
-					// After receiving the first notify, send un-subscribe.
-					if (eventSequence === 7) {
-						++eventSequence; // 'send un-subscribe'
-
-						subscriber.terminate(WEATHER_REQUEST);
-					}
-				});
-
-				subscriber.on('terminated', (terminationCode, reason, retryAfter) => {
-					expect(++eventSequence).toBe(12); // 'subscriber terminated'
-					expect(terminationCode).toBe(subscriber.C.FINAL_NOTIFY_RECEIVED);
-					expect(reason).toBeUndefined();
-					expect(retryAfter).toBeUndefined();
-
-					ua.stop();
-				});
-
-				subscriber.on('accepted', () => {
-					expect(++eventSequence).toBe(5); // 'initial subscribe accepted'
-				});
-
-				expect(++eventSequence).toBe(2); // 'send subscribe'
-
-				subscriber.subscribe(WEATHER_REQUEST);
-			}
-
-			/**
-			 * @param {JsSIP.UA} ua
-			 */
-			function createNotifier(ua, subscribe) {
-				const notifier = ua.notify(subscribe, CONTENT_TYPE, { pending: false });
-
-				// Receive subscribe (includes initial)
-				notifier.on('subscribe', (isUnsubscribe, subs, body, contType) => {
-					expect(subscribe.method).toBe('SUBSCRIBE');
-					expect(subscribe.getHeader('contact')).toBe(`<${CONTACT_URI}>`); // 'subscribe contact'
-					expect(subscribe.getHeader('accept')).toBe(SUBSCRIBE_ACCEPT); // 'subscribe accept'
-					expect(body).toBe(WEATHER_REQUEST); // 'subscribe body'
-					expect(contType).toBe(CONTENT_TYPE); // 'subscribe content-type'
-
-					expect(++eventSequence).toBe(isUnsubscribe ? 9 : 4);
-					if (isUnsubscribe) {
-						// 'send final notify'
-						notifier.terminate(WEATHER_REPORT);
-					} else {
-						// 'send notify'
-						notifier.notify(WEATHER_REPORT);
-					}
-				});
-
-				// Example only. Never reached.
-				notifier.on('expired', () => {
-					notifier.terminate(WEATHER_REPORT, 'timeout');
-				});
-
-				notifier.on('terminated', () => {
-					expect(++eventSequence).toBe(10); // 'notifier terminated'
-				});
-
-				notifier.start();
-			}
-
-			// Start JsSIP UA with loop socket.
-			const config = {
-				sockets: new LoopSocket(), // message sending itself, with modified Call-ID
-				uri: REQUEST_URI,
-				contact_uri: CONTACT_URI,
-				register: false,
-			};
-
-			const ua = new JsSIP.UA(config);
-
-			// Uncomment to see SIP communication
-			// JsSIP.debug.enable('JsSIP:*');
-
-			ua.on('newSubscribe', e => {
-				expect(++eventSequence).toBe(3); // 'receive initial subscribe'
-
-				const subs = e.request;
-				const ev = subs.parseHeader('event');
-
-				expect(subs.ruri.toString()).toBe(REQUEST_URI); // 'initial subscribe uri'
-				expect(ev.event).toBe(EVENT_NAME); // 'subscribe event'
-
-				if (ev.event !== EVENT_NAME) {
-					subs.reply(489); // "Bad Event"
-
-					return;
-				}
-
-				const accepts = subs.getHeaders('accept');
-				const canUse = accepts && accepts.some(v => v.includes(CONTENT_TYPE));
-
-				expect(canUse).toBe(true); // 'notifier can use subscribe accept header'
-
-				if (!canUse) {
-					subs.reply(406); // "Not Acceptable"
-
-					return;
-				}
-
-				createNotifier(ua, subs);
-			});
-
-			ua.on('connected', () => {
-				expect(++eventSequence).toBe(1); // 'socket connected'
-
-				createSubscriber(ua);
-			});
-
-			ua.on('disconnected', () => {
-				resolve();
-			});
-
-			ua.start();
-		}));
-});
diff --git a/src/test/test-classes.js b/src/test/test-classes.ts
similarity index 92%
rename from src/test/test-classes.js
rename to src/test/test-classes.ts
index 9b72b32..a92f9c3 100644
--- a/src/test/test-classes.js
+++ b/src/test/test-classes.ts
@@ -1,9 +1,12 @@
-require('./include/common');
-const JsSIP = require('../..');
+import './include/common';
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const JsSIP = require('../JsSIP.js');
+const { URI, NameAddrHeader } = JsSIP;

 describe('URI Tests', () => {
 	test('new URI', () => {
-		const uri = new JsSIP.URI(null, 'alice', 'jssip.net', 6060);
+		const uri = new URI(null, 'alice', 'jssip.net', 6060);

 		expect(uri.scheme).toBe('sip');
 		expect(uri.user).toBe('alice');
@@ -115,8 +118,8 @@ describe('URI Tests', () => {

 describe('NameAddr Tests', () => {
 	test('new NameAddr', () => {
-		const uri = new JsSIP.URI('sip', 'alice', 'jssip.net');
-		const name = new JsSIP.NameAddrHeader(uri, 'Alice æßð');
+		const uri = new URI('sip', 'alice', 'jssip.net');
+		const name = new NameAddrHeader(uri, 'Alice æßð');

 		expect(name.display_name).toBe('Alice æßð');
 		expect(name.toString()).toBe('"Alice æßð" <sip:alice@jssip.net>');
@@ -147,6 +150,5 @@ describe('NameAddr Tests', () => {
 		expect(name2.toString()).toBe(name.toString());
 		name2.display_name = '@ł€';
 		expect(name2.display_name).toBe('@ł€');
-		expect(name.user).toBeUndefined();
 	});
 });
diff --git a/src/test/test-digestAuthentication.js b/src/test/test-digestAuthentication.ts
similarity index 94%
rename from src/test/test-digestAuthentication.js
rename to src/test/test-digestAuthentication.ts
index 2068420..28a980b 100644
--- a/src/test/test-digestAuthentication.js
+++ b/src/test/test-digestAuthentication.ts
@@ -1,5 +1,6 @@
-require('./include/common');
-const DigestAuthentication = require('../DigestAuthentication.js');
+import './include/common';
+// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any
+const DigestAuthentication: any = require('../DigestAuthentication.js');

 // Results of this tests originally obtained from RFC 2617 and:
 // 'https://pernau.at/kd/sipdigest.php'
diff --git a/src/test/test-normalizeTarget.js b/src/test/test-normalizeTarget.ts
similarity index 74%
rename from src/test/test-normalizeTarget.js
rename to src/test/test-normalizeTarget.ts
index 1fb874a..07c43fd 100644
--- a/src/test/test-normalizeTarget.js
+++ b/src/test/test-normalizeTarget.ts
@@ -1,15 +1,18 @@
-require('./include/common');
-const JsSIP = require('../..');
+import './include/common';
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const JsSIP = require('../JsSIP.js');
+const { URI, Utils } = JsSIP;

 describe('normalizeTarget', () => {
 	test('valid targets', () => {
 		const domain = 'jssip.net';

-		function test_ok(given_data, expected) {
-			const uri = JsSIP.Utils.normalizeTarget(given_data, domain);
+		function test_ok(given_data: string, expected: string): void {
+			const uri = Utils.normalizeTarget(given_data, domain);

-			expect(uri instanceof JsSIP.URI).toBeTruthy();
-			expect(uri.toString()).toEqual(expected);
+			expect(uri instanceof URI).toBeTruthy();
+			expect(uri!.toString()).toEqual(expected);
 		}

 		test_ok('%61lice', 'sip:alice@jssip.net');
@@ -39,8 +42,10 @@ describe('normalizeTarget', () => {
 	test('invalid targets', () => {
 		const domain = 'jssip.net';

-		function test_error(given_data) {
-			expect(JsSIP.Utils.normalizeTarget(given_data, domain)).toBe(undefined);
+		function test_error(given_data: unknown): void {
+			expect(Utils.normalizeTarget(given_data as string, domain)).toBe(
+				undefined
+			);
 		}

 		test_error(null);
@@ -52,6 +57,6 @@ describe('normalizeTarget', () => {
 		test_error('ibc@iñaki.com');
 		test_error('ibc@aliax.net;;;;;');

-		expect(JsSIP.Utils.normalizeTarget('alice')).toBe(undefined);
+		expect(Utils.normalizeTarget('alice')).toBe(undefined);
 	});
 });
diff --git a/src/test/test-parser.js b/src/test/test-parser.ts
similarity index 80%
rename from src/test/test-parser.js
rename to src/test/test-parser.ts
index d6c6825..eee4e5c 100644
--- a/src/test/test-parser.js
+++ b/src/test/test-parser.ts
@@ -1,16 +1,20 @@
-require('./include/common');
-const JsSIP = require('../..');
-const testUA = require('./include/testUA');
-const Parser = require('../Parser');
+import './include/common';
+import * as consts from './include/consts';
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const JsSIP = require('../JsSIP.js');
+const { URI, NameAddrHeader, Grammar, WebSocketInterface, UA } = JsSIP;
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const Parser = require('../Parser.js');

 describe('parser', () => {
 	test('parse URI', () => {
 		const data =
 			'SIP:%61liCE@versaTICA.Com:6060;TRansport=TCp;Foo=ABc;baz?X-Header-1=AaA1&X-Header-2=BbB&x-header-1=AAA2';
-		const uri = JsSIP.URI.parse(data);
+		const uri = URI.parse(data);

 		// Parsed data.
-		expect(uri instanceof JsSIP.URI).toBeTruthy();
+		expect(uri instanceof URI).toBeTruthy();
 		expect(uri.scheme).toBe('sip');
 		expect(uri.user).toBe('aliCE');
 		expect(uri.host).toBe('versatica.com');
@@ -49,10 +53,10 @@ describe('parser', () => {
 		const data =
 			' "Iñaki ðđøþ foo \\"bar\\" \\\\\\\\ \\\\ \\\\d \\\\\\\\d \\\\\' \\\\\\"sdf\\\\\\"" ' +
 			'<SIP:%61liCE@versaTICA.Com:6060;TRansport=TCp;Foo=ABc;baz?X-Header-1=AaA1&X-Header-2=BbB&x-header-1=AAA2>;QWE=QWE;ASd';
-		const name = JsSIP.NameAddrHeader.parse(data);
+		const name = NameAddrHeader.parse(data);

 		// Parsed data.
-		expect(name instanceof JsSIP.NameAddrHeader).toBeTruthy();
+		expect(name instanceof NameAddrHeader).toBeTruthy();
 		expect(name.display_name).toBe(
 			'Iñaki ðđøþ foo "bar" \\\\ \\ \\d \\\\d \\\' \\"sdf\\"'
 		);
@@ -64,7 +68,7 @@ describe('parser', () => {

 		const uri = name.uri;

-		expect(uri instanceof JsSIP.URI).toBeTruthy();
+		expect(uri instanceof URI).toBeTruthy();
 		expect(uri.scheme).toBe('sip');
 		expect(uri.user).toBe('aliCE');
 		expect(uri.host).toBe('versatica.com');
@@ -94,15 +98,15 @@ describe('parser', () => {
 	test('parse invalid NameAddr with non UTF-8 characters', () => {
 		const buffer = Buffer.from([0xc0]);
 		const data = `"${buffer.toString()}"<sip:foo@bar.com>`;
-		const name = JsSIP.NameAddrHeader.parse(data);
+		const name = NameAddrHeader.parse(data);

 		// Parsed data.
-		expect(name instanceof JsSIP.NameAddrHeader).toBeTruthy();
+		expect(name instanceof NameAddrHeader).toBeTruthy();
 		expect(name.display_name).toBe(buffer.toString());

 		const uri = name.uri;

-		expect(uri instanceof JsSIP.URI).toBeTruthy();
+		expect(uri instanceof URI).toBeTruthy();
 		expect(uri.scheme).toBe('sip');
 		expect(uri.user).toBe('foo');
 		expect(uri.host).toBe('bar.com');
@@ -112,37 +116,37 @@ describe('parser', () => {
 	test('parse NameAddr with token display_name', () => {
 		const data =
 			'Foo    Foo Bar\tBaz<SIP:%61liCE@versaTICA.Com:6060;TRansport=TCp;Foo=ABc;baz?X-Header-1=AaA1&X-Header-2=BbB&x-header-1=AAA2>;QWE=QWE;ASd';
-		const name = JsSIP.NameAddrHeader.parse(data);
+		const name = NameAddrHeader.parse(data);

 		// Parsed data.
-		expect(name instanceof JsSIP.NameAddrHeader).toBeTruthy();
+		expect(name instanceof NameAddrHeader).toBeTruthy();
 		expect(name.display_name).toBe('Foo Foo Bar Baz');
 	});

 	test('parse NameAddr with no space between DQUOTE and LAQUOT', () => {
 		const data =
 			'"Foo"<SIP:%61liCE@versaTICA.Com:6060;TRansport=TCp;Foo=ABc;baz?X-Header-1=AaA1&X-Header-2=BbB&x-header-1=AAA2>;QWE=QWE;ASd';
-		const name = JsSIP.NameAddrHeader.parse(data);
+		const name = NameAddrHeader.parse(data);

 		// Parsed data.
-		expect(name instanceof JsSIP.NameAddrHeader).toBeTruthy();
+		expect(name instanceof NameAddrHeader).toBeTruthy();
 		expect(name.display_name).toBe('Foo');
 	});

 	test('parse NameAddr with no display_name', () => {
 		const data =
 			'<SIP:%61liCE@versaTICA.Com:6060;TRansport=TCp;Foo=ABc;baz?X-Header-1=AaA1&X-Header-2=BbB&x-header-1=AAA2>;QWE=QWE;ASd';
-		const name = JsSIP.NameAddrHeader.parse(data);
+		const name = NameAddrHeader.parse(data);

 		// Parsed data.
-		expect(name instanceof JsSIP.NameAddrHeader).toBeTruthy();
+		expect(name instanceof NameAddrHeader).toBeTruthy();
 		expect(name.display_name).toBe(undefined);
 	});

 	test('parse multiple Contact', () => {
 		const data =
 			'"Iñaki @ł€" <SIP:+1234@ALIAX.net;Transport=WS>;+sip.Instance="abCD", sip:bob@biloxi.COM;headerParam, <sip:DOMAIN.com:5>';
-		const contacts = JsSIP.Grammar.parse(data, 'Contact');
+		const contacts = Grammar.parse(data, 'Contact');

 		expect(contacts instanceof Array).toBeTruthy();
 		expect(contacts.length).toBe(3);
@@ -151,13 +155,13 @@ describe('parser', () => {
 		const c3 = contacts[2].parsed;

 		// Parsed data.
-		expect(c1 instanceof JsSIP.NameAddrHeader).toBeTruthy();
+		expect(c1 instanceof NameAddrHeader).toBeTruthy();
 		expect(c1.display_name).toBe('Iñaki @ł€');
 		expect(c1.hasParam('+sip.instance')).toBe(true);
 		expect(c1.hasParam('nooo')).toBe(false);
 		expect(c1.getParam('+SIP.instance')).toBe('"abCD"');
 		expect(c1.getParam('nooo')).toBe(undefined);
-		expect(c1.uri instanceof JsSIP.URI).toBeTruthy();
+		expect(c1.uri instanceof URI).toBeTruthy();
 		expect(c1.uri.scheme).toBe('sip');
 		expect(c1.uri.user).toBe('+1234');
 		expect(c1.uri.host).toBe('aliax.net');
@@ -184,10 +188,10 @@ describe('parser', () => {
 		);

 		// Parsed data.
-		expect(c2 instanceof JsSIP.NameAddrHeader).toBeTruthy();
+		expect(c2 instanceof NameAddrHeader).toBeTruthy();
 		expect(c2.display_name).toBe(undefined);
 		expect(c2.hasParam('HEADERPARAM')).toBe(true);
-		expect(c2.uri instanceof JsSIP.URI).toBeTruthy();
+		expect(c2.uri instanceof URI).toBeTruthy();
 		expect(c2.uri.scheme).toBe('sip');
 		expect(c2.uri.user).toBe('bob');
 		expect(c2.uri.host).toBe('biloxi.com');
@@ -200,9 +204,9 @@ describe('parser', () => {
 		expect(c2.toString()).toBe('"@ł€ĸłæß" <sip:bob@biloxi.com>;headerparam');

 		// Parsed data.
-		expect(c3 instanceof JsSIP.NameAddrHeader).toBeTruthy();
+		expect(c3 instanceof NameAddrHeader).toBeTruthy();
 		expect(c3.displayName).toBe(undefined);
-		expect(c3.uri instanceof JsSIP.URI).toBeTruthy();
+		expect(c3.uri instanceof URI).toBeTruthy();
 		expect(c3.uri.scheme).toBe('sip');
 		expect(c3.uri.user).toBe(undefined);
 		expect(c3.uri.host).toBe('domain.com');
@@ -221,7 +225,7 @@ describe('parser', () => {
 	test('parse Via', () => {
 		let data =
 			'SIP /  3.0 \r\n / UDP [1:ab::FF]:6060 ;\r\n  BRanch=1234;Param1=Foo;paRAM2;param3=Bar';
-		let via = JsSIP.Grammar.parse(data, 'Via');
+		let via = Grammar.parse(data, 'Via');

 		expect(via.protocol).toBe('SIP');
 		expect(via.transport).toBe('UDP');
@@ -237,7 +241,7 @@ describe('parser', () => {

 		data =
 			'SIP /  3.0 \r\n / UDP [1:ab::FF]:6060 ;\r\n  BRanch=1234;rport=1111;Param1=Foo;paRAM2;param3=Bar';
-		via = JsSIP.Grammar.parse(data, 'Via');
+		via = Grammar.parse(data, 'Via');

 		expect(via.protocol).toBe('SIP');
 		expect(via.transport).toBe('UDP');
@@ -254,7 +258,7 @@ describe('parser', () => {

 		data =
 			'SIP /  3.0 \r\n / UDP [1:ab::FF]:6060 ;\r\n  BRanch=1234;rport;Param1=Foo;paRAM2;param3=Bar';
-		via = JsSIP.Grammar.parse(data, 'Via');
+		via = Grammar.parse(data, 'Via');

 		expect(via.protocol).toBe('SIP');
 		expect(via.transport).toBe('UDP');
@@ -272,7 +276,7 @@ describe('parser', () => {

 	test('parse CSeq', () => {
 		const data = '123456  CHICKEN';
-		const cseq = JsSIP.Grammar.parse(data, 'CSeq');
+		const cseq = Grammar.parse(data, 'CSeq');

 		expect(cseq.value).toBe(123456);
 		expect(cseq.method).toBe('CHICKEN');
@@ -281,7 +285,7 @@ describe('parser', () => {
 	test('parse authentication challenge', () => {
 		const data =
 			'Digest realm =  "[1:ABCD::abc]", nonce =  "31d0a89ed7781ce6877de5cb032bf114", qop="AUTH,autH-INt", algorithm =  md5  ,  stale =  TRUE , opaque = "00000188"';
-		const auth = JsSIP.Grammar.parse(data, 'challenge');
+		const auth = Grammar.parse(data, 'challenge');

 		expect(auth.realm).toBe('[1:ABCD::abc]');
 		expect(auth.nonce).toBe('31d0a89ed7781ce6877de5cb032bf114');
@@ -293,7 +297,7 @@ describe('parser', () => {

 	test('parse Event', () => {
 		const data = 'Presence;Param1=QWe;paraM2';
-		const event = JsSIP.Grammar.parse(data, 'Event');
+		const event = Grammar.parse(data, 'Event');

 		expect(event.event).toBe('presence');
 		expect(event.params).toEqual({ param1: 'QWe', param2: undefined });
@@ -303,13 +307,13 @@ describe('parser', () => {
 		let data, session_expires;

 		data = '180;refresher=uac';
-		session_expires = JsSIP.Grammar.parse(data, 'Session_Expires');
+		session_expires = Grammar.parse(data, 'Session_Expires');

 		expect(session_expires.expires).toBe(180);
 		expect(session_expires.refresher).toBe('uac');

 		data = '210  ;   refresher  =  UAS ; foo  =  bar';
-		session_expires = JsSIP.Grammar.parse(data, 'Session_Expires');
+		session_expires = Grammar.parse(data, 'Session_Expires');

 		expect(session_expires.expires).toBe(210);
 		expect(session_expires.refresher).toBe('uas');
@@ -319,14 +323,14 @@ describe('parser', () => {
 		let data, reason;

 		data = 'SIP  ; cause = 488 ; text = "Wrong SDP"';
-		reason = JsSIP.Grammar.parse(data, 'Reason');
+		reason = Grammar.parse(data, 'Reason');

 		expect(reason.protocol).toBe('sip');
 		expect(reason.cause).toBe(488);
 		expect(reason.text).toBe('Wrong SDP');

 		data = 'ISUP; cause=500 ; LALA = foo';
-		reason = JsSIP.Grammar.parse(data, 'Reason');
+		reason = Grammar.parse(data, 'Reason');

 		expect(reason.protocol).toBe('isup');
 		expect(reason.cause).toBe(500);
@@ -338,33 +342,33 @@ describe('parser', () => {
 		let data, parsed;

 		data = 'versatica.com';
-		expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1);
+		expect((parsed = Grammar.parse(data, 'host'))).not.toBe(-1);
 		expect(parsed.host_type).toBe('domain');

 		data = 'myhost123';
-		expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1);
+		expect((parsed = Grammar.parse(data, 'host'))).not.toBe(-1);
 		expect(parsed.host_type).toBe('domain');

 		data = '1.2.3.4';
-		expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1);
+		expect((parsed = Grammar.parse(data, 'host'))).not.toBe(-1);
 		expect(parsed.host_type).toBe('IPv4');

 		data = '[1:0:fF::432]';
-		expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1);
+		expect((parsed = Grammar.parse(data, 'host'))).not.toBe(-1);
 		expect(parsed.host_type).toBe('IPv6');

 		data = '1.2.3.444';
-		expect((parsed = JsSIP.Grammar.parse(data, 'host'))).toBe(-1);
+		expect((parsed = Grammar.parse(data, 'host'))).toBe(-1);

 		data = 'iñaki.com';
-		expect((parsed = JsSIP.Grammar.parse(data, 'host'))).toBe(-1);
+		expect((parsed = Grammar.parse(data, 'host'))).toBe(-1);

 		data = '1.2.3.bar.qwe-asd.foo';
-		expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1);
+		expect((parsed = Grammar.parse(data, 'host'))).not.toBe(-1);
 		expect(parsed.host_type).toBe('domain');

 		data = '1.2.3.4.bar.qwe-asd.foo';
-		expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1);
+		expect((parsed = Grammar.parse(data, 'host'))).not.toBe(-1);
 		expect(parsed.host_type).toBe('domain');
 	});

@@ -372,13 +376,13 @@ describe('parser', () => {
 		let data, parsed;

 		data = 'sip:alice@versatica.com';
-		expect((parsed = JsSIP.Grammar.parse(data, 'Refer_To'))).not.toBe(-1);
+		expect((parsed = Grammar.parse(data, 'Refer_To'))).not.toBe(-1);
 		expect(parsed.uri.scheme).toBe('sip');
 		expect(parsed.uri.user).toBe('alice');
 		expect(parsed.uri.host).toBe('versatica.com');

 		data = '<sip:bob@versatica.com?Accept-Contact=sip:bobsdesk.versatica.com>';
-		expect((parsed = JsSIP.Grammar.parse(data, 'Refer_To'))).not.toBe(-1);
+		expect((parsed = Grammar.parse(data, 'Refer_To'))).not.toBe(-1);
 		expect(parsed.uri.scheme).toBe('sip');
 		expect(parsed.uri.user).toBe('bob');
 		expect(parsed.uri.host).toBe('versatica.com');
@@ -390,7 +394,7 @@ describe('parser', () => {

 		const data = '5t2gpbrbi72v79p1i8mr;to-tag=03aq91cl9n;from-tag=kun98clbf7';

-		expect((parsed = JsSIP.Grammar.parse(data, 'Replaces'))).not.toBe(-1);
+		expect((parsed = Grammar.parse(data, 'Replaces'))).not.toBe(-1);
 		expect(parsed.call_id).toBe('5t2gpbrbi72v79p1i8mr');
 		expect(parsed.to_tag).toBe('03aq91cl9n');
 		expect(parsed.from_tag).toBe('kun98clbf7');
@@ -400,7 +404,7 @@ describe('parser', () => {
 		const data = 'SIP/2.0 420 Bad Extension';
 		let parsed;

-		expect((parsed = JsSIP.Grammar.parse(data, 'Status_Line'))).not.toBe(-1);
+		expect((parsed = Grammar.parse(data, 'Status_Line'))).not.toBe(-1);
 		expect(parsed.status_code).toBe(420);
 	});

@@ -417,24 +421,22 @@ Max-Forwards: 70\r\n\
 Privacy: id\r\n\
 P-Preferred-Identity: "Cullen Jennings" <sip:fluffy@cisco.com>\r\n\r\n';

-		const config = testUA.UA_CONFIGURATION;
-		const wsSocket = new JsSIP.WebSocketInterface(
-			testUA.SOCKET_DESCRIPTION.url
-		);
+		const config = consts.UA_CONFIGURATION;
+		const wsSocket = new WebSocketInterface(consts.SOCKET_DESCRIPTION['url']);

-		config.sockets = wsSocket;
+		config['sockets'] = wsSocket;

-		const ua = new JsSIP.UA(config);
+		const ua = new UA(config as ConstructorParameters<typeof UA>[0]);
 		const message = Parser.parseMessage(data, ua);

 		expect(message.hasHeader('P-Preferred-Identity')).toBe(true);

 		const pai = message.getHeader('P-Preferred-Identity');
-		const nameAddress = JsSIP.NameAddrHeader.parse(pai);
+		const nameAddress = NameAddrHeader.parse(pai);

-		expect(nameAddress instanceof JsSIP.NameAddrHeader).toBeTruthy();
-		expect(nameAddress.uri.user).toBe('fluffy');
-		expect(nameAddress.uri.host).toBe('cisco.com');
+		expect(nameAddress instanceof NameAddrHeader).toBeTruthy();
+		expect(nameAddress!.uri.user).toBe('fluffy');
+		expect(nameAddress!.uri.host).toBe('cisco.com');

 		expect(message.hasHeader('Privacy')).toBe(true);
 		expect(message.getHeader('Privacy')).toBe('id');
diff --git a/src/test/test-properties.js b/src/test/test-properties.ts
similarity index 57%
rename from src/test/test-properties.js
rename to src/test/test-properties.ts
index 0d432ea..3231862 100644
--- a/src/test/test-properties.js
+++ b/src/test/test-properties.ts
@@ -1,5 +1,8 @@
-require('./include/common');
-const JsSIP = require('../..');
+import './include/common';
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const JsSIP = require('../JsSIP.js');
+// eslint-disable-next-line @typescript-eslint/no-require-imports
 const pkg = require('../../package.json');

 describe('Properties', () => {
diff --git a/src/test/test-subscriber-notifier.ts b/src/test/test-subscriber-notifier.ts
new file mode 100644
index 0000000..b96d8fd
--- /dev/null
+++ b/src/test/test-subscriber-notifier.ts
@@ -0,0 +1,221 @@
+import './include/common';
+import LoopSocket from './include/LoopSocket';
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const JsSIP = require('../JsSIP.js');
+const { UA } = JsSIP;
+
+const enum STEP {
+	INIT = 0,
+	SOCKET_CONNECTED = 1,
+	SUBSCRIBE_SENT = 2,
+	UA_ON_NEWSUBSCRIBE = 3,
+	NOTIFIER_ON_SUBSCRIBE = 4,
+	SUBSCRIBER_ON_ACCEPTED = 5,
+	SUBSCRIBER_ON_ACTIVE = 6,
+	SUBSCRIBER_ON_NOTIFY_1 = 7,
+	NOTIFIER_ON_UNSUBSCRIBE = 8,
+	NOTIFIER_TERMINATED = 9,
+	SUBSCRIBER_ON_NOTIFY_2 = 10,
+	SUBSCRIBER_TERMINATED = 11,
+}
+
+describe('subscriber/notifier communication', () => {
+	test('should handle subscriber/notifier communication', () =>
+		new Promise<void>(resolve => {
+			let step = STEP.INIT;
+
+			const TARGET = 'ikq';
+			const REQUEST_URI = 'sip:ikq@example.com';
+			const CONTACT_URI = 'sip:ikq@abcdefabcdef.invalid;transport=ws';
+			const SUBSCRIBE_ACCEPT = 'application/text, text/plain';
+			const EVENT_NAME = 'weather';
+			const CONTENT_TYPE = 'text/plain';
+			const WEATHER_REQUEST = 'Please report the weather condition';
+			const WEATHER_REPORT = '+20..+24°C, no precipitation, light wind';
+
+			// eslint-disable-next-line @typescript-eslint/no-explicit-any
+			function createSubscriber(ua: any): void {
+				const options = {
+					expires: 3600,
+					contentType: CONTENT_TYPE,
+				};
+
+				const subscriber = ua.subscribe(
+					TARGET,
+					EVENT_NAME,
+					SUBSCRIBE_ACCEPT,
+					options
+				);
+
+				subscriber.on('active', () => {
+					// 'receive notify with subscription-state: active'
+					expect(++step).toBe(STEP.SUBSCRIBER_ON_ACTIVE);
+				});
+
+				subscriber.on(
+					'notify',
+					(
+						isFinal: boolean,
+						notify: {
+							method: string;
+							getHeader: (name: string) => string;
+							parseHeader: (name: string) => { state: string };
+						},
+						body?: string,
+						contType?: string
+					) => {
+						step++;
+						// 'receive notify'
+						expect(
+							step === STEP.SUBSCRIBER_ON_NOTIFY_1 ||
+								step === STEP.SUBSCRIBER_ON_NOTIFY_2
+						).toBe(true);
+
+						expect(notify.method).toBe('NOTIFY');
+						expect(notify.getHeader('contact')).toBe(`<${CONTACT_URI}>`); // 'notify contact'
+						expect(body).toBe(WEATHER_REPORT); // 'notify body'
+						expect(contType).toBe(CONTENT_TYPE); // 'notify content-type'
+
+						const subsState = notify.parseHeader('subscription-state').state;
+
+						expect(
+							subsState === 'pending' ||
+								subsState === 'active' ||
+								subsState === 'terminated'
+						).toBe(true); // 'notify subscription-state'
+
+						// After receiving the first notify, send un-subscribe.
+						if (step === STEP.SUBSCRIBER_ON_NOTIFY_1) {
+							subscriber.terminate(WEATHER_REQUEST);
+						}
+					}
+				);
+
+				subscriber.on(
+					'terminated',
+					(
+						terminationCode: number,
+						reason: string | undefined,
+						retryAfter: number | undefined
+					) => {
+						expect(++step).toBe(STEP.SUBSCRIBER_TERMINATED);
+						expect(terminationCode).toBe(subscriber.C.FINAL_NOTIFY_RECEIVED);
+						expect(reason).toBeUndefined();
+						expect(retryAfter).toBeUndefined();
+
+						ua.stop();
+					}
+				);
+
+				subscriber.on('accepted', () => {
+					expect(++step).toBe(STEP.SUBSCRIBER_ON_ACCEPTED);
+				});
+
+				subscriber.subscribe(WEATHER_REQUEST);
+
+				expect(++step).toBe(STEP.SUBSCRIBE_SENT);
+			}
+
+			// eslint-disable-next-line @typescript-eslint/no-explicit-any
+			function createNotifier(ua: any, subscribe: any): void {
+				const notifier = ua.notify(subscribe, CONTENT_TYPE, { pending: false });
+
+				// Receive subscribe (includes initial)
+				notifier.on(
+					'subscribe',
+					(
+						isUnsubscribe: boolean,
+						subs: unknown,
+						body?: string,
+						contType?: string
+					) => {
+						expect(subscribe.method).toBe('SUBSCRIBE');
+						expect(subscribe.getHeader('contact')).toBe(`<${CONTACT_URI}>`); // 'subscribe contact'
+						expect(subscribe.getHeader('accept')).toBe(SUBSCRIBE_ACCEPT); // 'subscribe accept'
+						expect(body).toBe(WEATHER_REQUEST); // 'subscribe body'
+						expect(contType).toBe(CONTENT_TYPE); // 'subscribe content-type'
+
+						expect(++step).toBe(
+							isUnsubscribe
+								? STEP.NOTIFIER_ON_UNSUBSCRIBE
+								: STEP.NOTIFIER_ON_SUBSCRIBE
+						);
+						if (isUnsubscribe) {
+							// 'send final notify'
+							notifier.terminate(WEATHER_REPORT);
+						} else {
+							// 'send notify'
+							notifier.notify(WEATHER_REPORT);
+						}
+					}
+				);
+
+				// Example only. Never reached.
+				notifier.on('expired', () => {
+					notifier.terminate(WEATHER_REPORT, 'timeout');
+				});
+
+				notifier.on('terminated', () => {
+					expect(++step).toBe(STEP.NOTIFIER_TERMINATED);
+				});
+
+				notifier.start();
+			}
+
+			// Start JsSIP UA with loop socket.
+			const config = {
+				sockets: new LoopSocket(), // message sending itself, with modified Call-ID
+				uri: REQUEST_URI,
+				contact_uri: CONTACT_URI,
+				register: false,
+			};
+
+			const ua = new UA(config);
+
+			// Uncomment to see SIP communication
+			// JsSIP.debug.enable('JsSIP:*');
+
+			// eslint-disable-next-line @typescript-eslint/no-explicit-any
+			ua.on('newSubscribe', (e: any) => {
+				expect(++step).toBe(STEP.UA_ON_NEWSUBSCRIBE);
+
+				const subs = e.request;
+				const ev = subs.parseHeader('event');
+
+				expect(subs.ruri.toString()).toBe(REQUEST_URI); // 'initial subscribe uri'
+				expect(ev.event).toBe(EVENT_NAME); // 'subscribe event'
+
+				if (ev.event !== EVENT_NAME) {
+					subs.reply(489); // "Bad Event"
+
+					return;
+				}
+
+				const accepts = subs.getHeaders('accept');
+				const canUse = accepts?.some((v: string) => v.includes(CONTENT_TYPE));
+
+				expect(canUse).toBe(true); // 'notifier can use subscribe accept header'
+
+				if (!canUse) {
+					subs.reply(406); // "Not Acceptable"
+
+					return;
+				}
+
+				createNotifier(ua, subs);
+			});
+
+			ua.on('connected', () => {
+				expect(++step).toBe(STEP.SOCKET_CONNECTED);
+
+				createSubscriber(ua);
+			});
+
+			ua.on('disconnected', () => {
+				resolve();
+			});
+
+			ua.start();
+		}));
+});