@afterlink/cluster 2.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ajju (Javali Ajayakumar)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # @afterlink/cluster
2
+
3
+ AfterLink multi-process clustering orchestrator with Redis pub/sub synchronization.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @afterlink/cluster
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```javascript
14
+ const { createCluster } = require('@afterlink/cluster');
15
+ const Server = require('@afterlink/server');
16
+
17
+ createCluster({
18
+ workers: 4,
19
+ redis: {
20
+ host: 'localhost',
21
+ port: 6379
22
+ }
23
+ }, () => {
24
+ const server = new Server();
25
+ server.listen(4000);
26
+ });
27
+ ```
package/index.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { EventEmitter } from 'events';
2
+
3
+ export interface RedisConfig {
4
+ host?: string;
5
+ port?: number;
6
+ password?: string;
7
+ tls?: boolean;
8
+ keyPrefix?: string;
9
+ [key: string]: any;
10
+ }
11
+
12
+ export interface ClusterConfig {
13
+ workers?: number;
14
+ restartOnCrash?: boolean;
15
+ restartDelay?: number;
16
+ gracefulTimeout?: number;
17
+ redis?: RedisConfig;
18
+ }
19
+
20
+ export class ClusterManager extends EventEmitter {
21
+ constructor(config: ClusterConfig);
22
+ start(): void;
23
+ forkWorker(): any;
24
+ getAggregatedStats(): Record<string, any>;
25
+ shutdown(): void;
26
+ }
27
+
28
+ export function createCluster(
29
+ config: ClusterConfig | undefined,
30
+ workerFn: () => void
31
+ ): ClusterManager | null;
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@afterlink/cluster",
3
+ "version": "2.0.1",
4
+ "description": "AfterLink Cluster — Multi-process clustering with Redis pub/sub",
5
+ "main": "src/index.js",
6
+ "types": "index.d.ts",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/AJAYMYTH/AfterLink.git",
11
+ "directory": "packages/cluster"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "peerDependencies": {
17
+ "@afterlink/server": "2.0.1"
18
+ },
19
+ "dependencies": {
20
+ "ioredis": "^5.3.0"
21
+ },
22
+ "devDependencies": {
23
+ "vitest": "^2.1.0",
24
+ "@afterlink/server": "2.0.1"
25
+ },
26
+ "scripts": {
27
+ "test": "vitest run"
28
+ }
29
+ }
package/src/index.js ADDED
@@ -0,0 +1,55 @@
1
+ const cluster = require('cluster');
2
+ const os = require('os');
3
+ const ClusterManager = require('./manager');
4
+
5
+ /**
6
+ * Creates and starts a multi-process cluster for AfterLink servers.
7
+ * If the process is primary, it initializes the ClusterManager to coordinate workers.
8
+ * If the process is a worker, it runs the user-supplied workerFn.
9
+ *
10
+ * @param {Object} [config] Cluster configuration options
11
+ * @param {Function} workerFn Function to run in worker processes (typically starting a Server)
12
+ * @returns {ClusterManager|null} The ClusterManager instance if primary, otherwise null
13
+ */
14
+ function createCluster(config = {}, workerFn) {
15
+ if (typeof workerFn !== 'function') {
16
+ throw new TypeError('workerFn must be a function');
17
+ }
18
+
19
+ const defaultConfig = {
20
+ workers: os.cpus().length,
21
+ restartOnCrash: true,
22
+ restartDelay: 1000,
23
+ gracefulTimeout: 10000,
24
+ redis: {
25
+ host: 'localhost',
26
+ port: 6379,
27
+ password: undefined,
28
+ tls: false,
29
+ keyPrefix: 'afterlink:',
30
+ }
31
+ };
32
+
33
+ const mergedConfig = {
34
+ ...defaultConfig,
35
+ ...config,
36
+ redis: {
37
+ ...defaultConfig.redis,
38
+ ...(config.redis || {})
39
+ }
40
+ };
41
+
42
+ if (cluster.isPrimary || cluster.isMaster) {
43
+ const manager = new ClusterManager(mergedConfig);
44
+ manager.start();
45
+ return manager;
46
+ } else {
47
+ workerFn();
48
+ return null;
49
+ }
50
+ }
51
+
52
+ module.exports = {
53
+ createCluster,
54
+ ClusterManager
55
+ };
package/src/manager.js ADDED
@@ -0,0 +1,141 @@
1
+ const cluster = require('cluster');
2
+ const EventEmitter = require('events');
3
+ const { setupRollingRestart } = require('./rolling-restart');
4
+
5
+ /**
6
+ * ClusterManager class responsible for primary process operations,
7
+ * worker lifecycle monitoring, and IPC metrics aggregation.
8
+ */
9
+ class ClusterManager extends EventEmitter {
10
+ constructor(config) {
11
+ super();
12
+ this.config = config;
13
+ this.workers = new Map();
14
+ this.workerStats = new Map();
15
+ this.isShuttingDown = false;
16
+ }
17
+
18
+ /**
19
+ * Starts monitoring exit events and spawns worker processes.
20
+ */
21
+ start() {
22
+ console.log(`[AfterLink Cluster] Starting primary process ${process.pid}`);
23
+ console.log(`[AfterLink Cluster] Spawning ${this.config.workers} worker processes...`);
24
+
25
+ for (let i = 0; i < this.config.workers; i++) {
26
+ this.forkWorker();
27
+ }
28
+
29
+ cluster.on('exit', (worker, code, signal) => {
30
+ const pid = worker.process.pid;
31
+ this.workers.delete(pid);
32
+ this.workerStats.delete(pid);
33
+
34
+ if (this.isShuttingDown) return;
35
+
36
+ console.warn(`[AfterLink Cluster] Worker ${pid} exited with code ${code} (signal: ${signal})`);
37
+
38
+ if (this.config.restartOnCrash) {
39
+ console.log(`[AfterLink Cluster] Restarting crashed worker in ${this.config.restartDelay}ms...`);
40
+ setTimeout(() => {
41
+ if (this.isShuttingDown) return;
42
+ this.forkWorker();
43
+ }, this.config.restartDelay);
44
+ }
45
+ });
46
+
47
+ setupRollingRestart(cluster, this.config);
48
+ }
49
+
50
+ /**
51
+ * Forks a new worker and hooks up message handler for stats gathering.
52
+ */
53
+ forkWorker() {
54
+ const worker = cluster.fork();
55
+ const pid = worker.process.pid;
56
+ this.workers.set(pid, worker);
57
+ console.log(`[AfterLink Cluster] Worker ${pid} online`);
58
+
59
+ worker.on('message', (msg) => {
60
+ if (msg && msg.type === 'stats') {
61
+ this.workerStats.set(pid, msg.data);
62
+ }
63
+ });
64
+
65
+ return worker;
66
+ }
67
+
68
+ /**
69
+ * Combines/aggregates metrics reported by individual worker processes.
70
+ *
71
+ * @returns {Object} Aggregated cluster statistics
72
+ */
73
+ getAggregatedStats() {
74
+ let connections = 0;
75
+ let totalRequests = 0;
76
+ let requestsPerSec = 0;
77
+ let avgLatencyMsSum = 0;
78
+ let errorRateSum = 0;
79
+ const routeStatsMap = new Map();
80
+
81
+ const statsArray = Array.from(this.workerStats.values());
82
+ const count = statsArray.length;
83
+
84
+ for (const stats of statsArray) {
85
+ connections += stats.connections || 0;
86
+ totalRequests += stats.totalRequests || 0;
87
+ requestsPerSec += stats.requestsPerSec || 0;
88
+ avgLatencyMsSum += stats.avgLatencyMs || 0;
89
+ errorRateSum += stats.errorRate || 0;
90
+
91
+ if (stats.routes) {
92
+ for (const route of stats.routes) {
93
+ if (!routeStatsMap.has(route.name)) {
94
+ routeStatsMap.set(route.name, { totalCalls: 0, latencySum: 0, errorCount: 0 });
95
+ }
96
+ const current = routeStatsMap.get(route.name);
97
+ current.totalCalls += route.totalCalls || 0;
98
+ current.latencySum += (route.avgLatencyMs || 0) * (route.totalCalls || 0);
99
+ current.errorCount += (route.errorRate || 0) * (route.totalCalls || 0);
100
+ }
101
+ }
102
+ }
103
+
104
+ const routes = [];
105
+ for (const [name, data] of routeStatsMap.entries()) {
106
+ routes.push({
107
+ name,
108
+ totalCalls: data.totalCalls,
109
+ avgLatencyMs: data.totalCalls > 0 ? parseFloat((data.latencySum / data.totalCalls).toFixed(2)) : 0,
110
+ errorRate: data.totalCalls > 0 ? parseFloat((data.errorCount / data.totalCalls).toFixed(4)) : 0,
111
+ });
112
+ }
113
+
114
+ return {
115
+ workers: count,
116
+ connections,
117
+ totalRequests,
118
+ requestsPerSec: parseFloat(requestsPerSec.toFixed(2)),
119
+ avgLatencyMs: count > 0 ? parseFloat((avgLatencyMsSum / count).toFixed(2)) : 0,
120
+ errorRate: count > 0 ? parseFloat((errorRateSum / count).toFixed(4)) : 0,
121
+ routes
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Shuts down the cluster by notifying all workers.
127
+ */
128
+ shutdown() {
129
+ this.isShuttingDown = true;
130
+ console.log('[AfterLink Cluster] Shutting down cluster...');
131
+ for (const worker of this.workers.values()) {
132
+ try {
133
+ worker.send({ type: 'shutdown', timeout: this.config.gracefulTimeout });
134
+ } catch (err) {
135
+ // Channel may already be closed
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ module.exports = ClusterManager;
@@ -0,0 +1,135 @@
1
+ const Redis = require('ioredis');
2
+
3
+ /**
4
+ * RedisAdapter bridges cluster-wide Pub/Sub using ioredis.
5
+ * If Redis is disconnected, it falls back to worker-local pub/sub cleanly.
6
+ */
7
+ class RedisAdapter {
8
+ constructor(config = {}) {
9
+ const redisConfig = {
10
+ host: config.host || 'localhost',
11
+ port: config.port || 6379,
12
+ password: config.password,
13
+ tls: config.tls ? {} : undefined,
14
+ ...config
15
+ };
16
+
17
+ this.keyPrefix = config.keyPrefix || 'afterlink:';
18
+ this.channelName = `${this.keyPrefix}pubsub`;
19
+ this.localBroadcast = () => {};
20
+ this.degraded = false;
21
+ this.redisPub = null;
22
+ this.redisSub = null;
23
+
24
+ this.init(redisConfig);
25
+ }
26
+
27
+ /**
28
+ * Initializes pub and sub connections to Redis.
29
+ */
30
+ init(redisConfig) {
31
+ try {
32
+ this.redisPub = new Redis(redisConfig);
33
+ this.redisSub = new Redis(redisConfig);
34
+
35
+ const handleError = (err) => {
36
+ if (!this.degraded) {
37
+ console.warn(`[AfterLink Cluster] Redis connection failed, falling back to local-only pub/sub: ${err.message}`);
38
+ this.degraded = true;
39
+ }
40
+ };
41
+
42
+ this.redisPub.on('error', handleError);
43
+ this.redisSub.on('error', handleError);
44
+
45
+ this.redisPub.on('connect', () => {
46
+ this.degraded = false;
47
+ });
48
+
49
+ this.redisSub.on('connect', () => {
50
+ this.redisSub.subscribe(this.channelName).catch(handleError);
51
+ });
52
+
53
+ this.redisSub.on('message', (channel, message) => {
54
+ if (channel !== this.channelName) return;
55
+
56
+ try {
57
+ const parsed = JSON.parse(message);
58
+ if (parsed && parsed.pid !== process.pid) {
59
+ this.localBroadcast(parsed.topic, parsed.data);
60
+ }
61
+ } catch (err) {
62
+ console.error('[AfterLink Cluster] Failed to parse Redis pubsub message:', err.message);
63
+ }
64
+ });
65
+ } catch (err) {
66
+ console.warn(`[AfterLink Cluster] Redis initialization failed, falling back to local-only pub/sub: ${err.message}`);
67
+ this.degraded = true;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Registers local broadcaster callback.
73
+ */
74
+ setLocalBroadcast(fn) {
75
+ if (typeof fn === 'function') {
76
+ this.localBroadcast = fn;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Publishes message globally across cluster.
82
+ */
83
+ async publish(topic, data) {
84
+ // Always trigger locally
85
+ this.localBroadcast(topic, data);
86
+
87
+ if (!this.degraded && this.redisPub && this.redisPub.status === 'ready') {
88
+ try {
89
+ const payload = JSON.stringify({
90
+ topic,
91
+ data,
92
+ pid: process.pid
93
+ });
94
+ await this.redisPub.publish(this.channelName, payload);
95
+ } catch (err) {
96
+ console.warn('[AfterLink Cluster] Redis publish failed, falling back to local broadcast:', err.message);
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Returns current active state of the Redis connections.
103
+ */
104
+ isConnected() {
105
+ return !this.degraded &&
106
+ this.redisPub && this.redisPub.status === 'ready' &&
107
+ this.redisSub && this.redisSub.status === 'ready';
108
+ }
109
+
110
+ /**
111
+ * Pings redis and returns round-trip latency.
112
+ */
113
+ async getLatency() {
114
+ if (!this.isConnected()) return -1;
115
+ const start = Date.now();
116
+ try {
117
+ await this.redisPub.ping();
118
+ return Date.now() - start;
119
+ } catch (err) {
120
+ return -1;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Closes Redis client connections.
126
+ */
127
+ async close() {
128
+ const promises = [];
129
+ if (this.redisPub) promises.push(this.redisPub.quit().catch(() => {}));
130
+ if (this.redisSub) promises.push(this.redisSub.quit().catch(() => {}));
131
+ await Promise.all(promises);
132
+ }
133
+ }
134
+
135
+ module.exports = RedisAdapter;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * rolling-restart.js handles zero-downtime restarts.
3
+ * Sequentially terminates workers and spins up replacements when SIGUSR2 is received.
4
+ */
5
+ function setupRollingRestart(cluster, config) {
6
+ process.on('SIGUSR2', async () => {
7
+ console.log('[AfterLink Cluster] Rolling restart (SIGUSR2) initiated...');
8
+
9
+ const workers = Object.values(cluster.workers);
10
+ const gracefulTimeout = config.gracefulTimeout || 10000;
11
+
12
+ for (let i = 0; i < workers.length; i++) {
13
+ const worker = workers[i];
14
+ if (!worker) continue;
15
+
16
+ console.log(`[AfterLink Cluster] Stopping worker ${worker.process.pid}...`);
17
+
18
+ try {
19
+ worker.send({ type: 'shutdown', timeout: gracefulTimeout });
20
+ worker.disconnect();
21
+ } catch (err) {
22
+ // Channel may be dead
23
+ }
24
+
25
+ const exitPromise = new Promise((resolve) => {
26
+ const timeout = setTimeout(() => {
27
+ try {
28
+ worker.kill('SIGKILL');
29
+ } catch (err) {}
30
+ resolve();
31
+ }, gracefulTimeout + 1000);
32
+
33
+ worker.on('exit', () => {
34
+ clearTimeout(timeout);
35
+ resolve();
36
+ });
37
+ });
38
+
39
+ await exitPromise;
40
+
41
+ const newWorker = cluster.fork();
42
+ console.log(`[AfterLink Cluster] Spawned replacement worker ${newWorker.process.pid}`);
43
+
44
+ await new Promise((resolve) => {
45
+ newWorker.on('online', resolve);
46
+ setTimeout(resolve, 1000); // Fallback
47
+ });
48
+
49
+ // 500ms stabilization gap
50
+ await new Promise((resolve) => setTimeout(resolve, 500));
51
+ }
52
+
53
+ console.log('[AfterLink Cluster] Rolling restart complete.');
54
+ });
55
+ }
56
+
57
+ module.exports = {
58
+ setupRollingRestart
59
+ };
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import ClusterManager from '../src/manager';
3
+
4
+ describe('ClusterManager', () => {
5
+ let manager;
6
+
7
+ beforeEach(() => {
8
+ manager = new ClusterManager({ workers: 2 });
9
+ });
10
+
11
+ it('aggregates stats correctly from multiple workers', () => {
12
+ manager.workerStats.set(1001, {
13
+ connections: 5,
14
+ totalRequests: 100,
15
+ requestsPerSec: 10,
16
+ avgLatencyMs: 12.5,
17
+ errorRate: 0.02,
18
+ routes: [
19
+ { name: 'users/get', totalCalls: 80, avgLatencyMs: 10, errorRate: 0.01 },
20
+ { name: 'users/create', totalCalls: 20, avgLatencyMs: 22.5, errorRate: 0.06 }
21
+ ]
22
+ });
23
+
24
+ manager.workerStats.set(1002, {
25
+ connections: 3,
26
+ totalRequests: 50,
27
+ requestsPerSec: 5,
28
+ avgLatencyMs: 15.0,
29
+ errorRate: 0.04,
30
+ routes: [
31
+ { name: 'users/get', totalCalls: 40, avgLatencyMs: 12, errorRate: 0.02 },
32
+ { name: 'users/create', totalCalls: 10, avgLatencyMs: 27.0, errorRate: 0.12 }
33
+ ]
34
+ });
35
+
36
+ const aggregated = manager.getAggregatedStats();
37
+
38
+ expect(aggregated.workers).toBe(2);
39
+ expect(aggregated.connections).toBe(8);
40
+ expect(aggregated.totalRequests).toBe(150);
41
+ expect(aggregated.requestsPerSec).toBe(15);
42
+ expect(aggregated.avgLatencyMs).toBe(13.75); // (12.5 + 15.0) / 2
43
+ expect(aggregated.errorRate).toBe(0.03); // (0.02 + 0.04) / 2
44
+
45
+ const getRoute = aggregated.routes.find(r => r.name === 'users/get');
46
+ expect(getRoute).toBeDefined();
47
+ expect(getRoute.totalCalls).toBe(120);
48
+ expect(getRoute.avgLatencyMs).toBe(10.67);
49
+ expect(getRoute.errorRate).toBe(0.0133);
50
+ });
51
+ });
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ const mockOn = vi.fn();
4
+ const mockPublish = vi.fn().mockResolvedValue(1);
5
+ const mockSubscribe = vi.fn().mockResolvedValue(null);
6
+ const mockPing = vi.fn().mockResolvedValue('PONG');
7
+ const mockQuit = vi.fn().mockResolvedValue(null);
8
+
9
+ class MockRedis {
10
+ constructor() {
11
+ this.status = 'ready';
12
+ this.on = mockOn;
13
+ this.publish = mockPublish;
14
+ this.subscribe = mockSubscribe;
15
+ this.ping = mockPing;
16
+ this.quit = mockQuit;
17
+ }
18
+ }
19
+ MockRedis.default = MockRedis;
20
+
21
+ // Hijack the Node require cache for ioredis
22
+ const ioredisPath = require.resolve('ioredis');
23
+ require.cache[ioredisPath] = {
24
+ id: ioredisPath,
25
+ filename: ioredisPath,
26
+ loaded: true,
27
+ exports: MockRedis
28
+ };
29
+
30
+ // Import after cache hijacking
31
+ const RedisAdapter = require('../src/redis-adapter');
32
+
33
+ describe('RedisAdapter', () => {
34
+ let adapter;
35
+
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ adapter = new RedisAdapter({ host: 'mockhost', port: 6379 });
39
+ });
40
+
41
+ it('initializes redis connections', () => {
42
+ expect(adapter.redisPub).toBeDefined();
43
+ expect(adapter.redisSub).toBeDefined();
44
+ expect(adapter.degraded).toBe(false);
45
+ });
46
+
47
+ it('triggers local broadcast on publish', async () => {
48
+ const broadcastSpy = vi.fn();
49
+ adapter.setLocalBroadcast(broadcastSpy);
50
+
51
+ await adapter.publish('test-topic', { foo: 'bar' });
52
+
53
+ expect(broadcastSpy).toHaveBeenCalledWith('test-topic', { foo: 'bar' });
54
+ expect(mockPublish).toHaveBeenCalledWith(
55
+ 'afterlink:pubsub',
56
+ expect.stringContaining('"topic":"test-topic"')
57
+ );
58
+ });
59
+
60
+ it('ignores pubsub messages from same PID', () => {
61
+ const broadcastSpy = vi.fn();
62
+ adapter.setLocalBroadcast(broadcastSpy);
63
+
64
+ const messageCall = mockOn.mock.calls.find(call => call[0] === 'message');
65
+ expect(messageCall).toBeDefined();
66
+
67
+ // Trigger message event handler
68
+ messageCall[1]('afterlink:pubsub', JSON.stringify({
69
+ topic: 'test-topic',
70
+ data: { hello: 'world' },
71
+ pid: process.pid
72
+ }));
73
+
74
+ expect(broadcastSpy).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it('triggers local broadcast for messages from other PID', () => {
78
+ const broadcastSpy = vi.fn();
79
+ adapter.setLocalBroadcast(broadcastSpy);
80
+
81
+ const messageCall = mockOn.mock.calls.find(call => call[0] === 'message');
82
+ const messageHandler = messageCall[1];
83
+
84
+ messageHandler('afterlink:pubsub', JSON.stringify({
85
+ topic: 'test-topic',
86
+ data: { hello: 'world' },
87
+ pid: 99999
88
+ }));
89
+
90
+ expect(broadcastSpy).toHaveBeenCalledWith('test-topic', { hello: 'world' });
91
+ });
92
+
93
+ it('marks adapter degraded on error', () => {
94
+ const errorCall = mockOn.mock.calls.find(call => call[0] === 'error');
95
+ expect(errorCall).toBeDefined();
96
+ const errorHandler = errorCall[1];
97
+
98
+ errorHandler(new Error('Connection lost'));
99
+ expect(adapter.degraded).toBe(true);
100
+ });
101
+ });