serverless-ircd 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +160 -200
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/cf-tcp-container/package.json +1 -1
  6. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  7. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  8. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  9. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  10. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  11. package/apps/cf-worker/package.json +1 -1
  12. package/apps/local-cli/package.json +1 -1
  13. package/apps/local-cli/src/server.ts +94 -31
  14. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  15. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  16. package/apps/local-cli/tests/rehash.test.ts +147 -0
  17. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  18. package/apps/local-cli/tests/tcp.test.ts +89 -0
  19. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  20. package/apps/web/landing/index.html +226 -3
  21. package/apps/web/package.json +2 -1
  22. package/apps/web/scripts/build.mjs +25 -2
  23. package/apps/web/src/render-docs.ts +292 -0
  24. package/apps/web/tests/build-smoke.test.ts +31 -2
  25. package/apps/web/tests/landing-content.test.ts +103 -0
  26. package/apps/web/tests/render-docs.test.ts +198 -0
  27. package/docs/AWS-Adapter-Architecture.md +3 -2
  28. package/docs/Services.md +33 -1
  29. package/package.json +2 -2
  30. package/packages/aws-adapter/package.json +1 -1
  31. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  32. package/packages/aws-adapter/src/handlers/nlb-stream.ts +10 -2
  33. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  34. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  35. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  36. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  37. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  38. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  39. package/packages/cf-adapter/package.json +1 -1
  40. package/packages/cf-adapter/src/connection-do.ts +18 -6
  41. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  42. package/packages/in-memory-runtime/package.json +1 -1
  43. package/packages/irc-core/package.json +1 -1
  44. package/packages/irc-core/src/commands/account-auth.ts +46 -18
  45. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  46. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  47. package/packages/irc-core/src/commands/index.ts +1 -0
  48. package/packages/irc-core/src/commands/join.ts +41 -35
  49. package/packages/irc-core/src/commands/nickserv.ts +16 -4
  50. package/packages/irc-core/src/commands/registration.ts +27 -16
  51. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  52. package/packages/irc-core/src/commands/topic.ts +23 -10
  53. package/packages/irc-core/src/state/channel.ts +17 -0
  54. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  55. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  56. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  57. package/packages/irc-core/tests/commands/nickserv.test.ts +185 -2
  58. package/packages/irc-core/tests/commands/registration.test.ts +227 -6
  59. package/packages/irc-core/tests/commands/sasl.test.ts +44 -0
  60. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  61. package/packages/irc-server/package.json +1 -1
  62. package/packages/irc-server/src/actor.ts +80 -30
  63. package/packages/irc-server/tests/actor.test.ts +365 -3
  64. package/packages/irc-test-support/package.json +1 -1
  65. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  66. package/packages/irc-test-support/src/scenarios.ts +21 -6
  67. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  68. package/packages/irc-test-support/vitest.config.ts +6 -1
  69. package/tools/ci-hardening/package.json +1 -1
  70. package/tools/load-test/package.json +1 -1
  71. package/tools/load-test/src/client.ts +13 -13
  72. package/tools/load-test/tests/client.test.ts +258 -2
  73. package/tools/load-test/tests/config.test.ts +39 -0
  74. package/tools/load-test/tests/harness.test.ts +21 -0
  75. package/tools/load-test/tests/metrics.test.ts +7 -0
  76. package/tools/tcp-ws-forwarder/package.json +1 -1
  77. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  78. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  79. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  80. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
@@ -1,7 +1,8 @@
1
+ import { EventEmitter } from 'node:events';
1
2
  import { type LocalServer, startLocalServer } from '@serverless-ircd/local-cli/dist/server.js';
2
- import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
3
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
3
4
  import { WebSocketServer } from 'ws';
4
- import { IrcLoadClient } from '../src/client';
5
+ import { IrcLoadClient, type WebSocketCtor } from '../src/client';
5
6
  import { resolveConfig } from '../src/config';
6
7
 
7
8
  /**
@@ -210,3 +211,258 @@ function startDroppingWsServer(): Promise<{ port: number; close: () => Promise<v
210
211
  wss.once('error', onError);
211
212
  });
212
213
  }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Failure-path coverage via an injectable fake WebSocket.
217
+ //
218
+ // The client accepts a `WebSocketCtor` dependency (client.ts) so the lifecycle
219
+ // can be driven against a scriptable fake. The four tests below exercise the
220
+ // branches that are impractical to reach through a real socket: connect
221
+ // timeout (socket never opens), join timeout, privmsg timeout, and a socket
222
+ // `error` event fanning out to pending waiters mid-wait.
223
+ // ---------------------------------------------------------------------------
224
+
225
+ /** A reply the fake fires when the client sends a matching line. */
226
+ type FakeHandler = {
227
+ match: RegExp;
228
+ action: { kind: 'messages'; lines: string[] } | { kind: 'error'; error: unknown };
229
+ };
230
+
231
+ /** Module-level fake controller state (reset per test in beforeEach). */
232
+ let fakeAutoOpen = true;
233
+ let fakeHandlers: FakeHandler[] = [];
234
+ let fakeReadyState: number | undefined;
235
+ let fakeErrorOnClose = false;
236
+ let lastFake: FakeSocket | undefined;
237
+
238
+ /**
239
+ * Minimal WebSocket stand-in. Emits `open` on the next microtask (so the
240
+ * client's `once('open')` listener is attached first), and on `send()` fires
241
+ * any scripted reply for the sent line. `emit('error', ...)` is used to drive
242
+ * the socket-error path.
243
+ */
244
+ class FakeSocket extends EventEmitter {
245
+ readonly OPEN = 1;
246
+ readyState: number;
247
+ readonly sent: string[] = [];
248
+
249
+ constructor(_url: string, _protocols: string[]) {
250
+ super();
251
+ lastFake = this;
252
+ this.readyState = fakeReadyState ?? 1;
253
+ if (fakeAutoOpen) {
254
+ queueMicrotask(() => this.emit('open'));
255
+ }
256
+ }
257
+
258
+ send(line: string): void {
259
+ this.sent.push(line);
260
+ for (const h of fakeHandlers) {
261
+ if (h.match.test(line)) {
262
+ const action = h.action;
263
+ queueMicrotask(() => {
264
+ if (action.kind === 'messages') {
265
+ for (const text of action.lines) {
266
+ this.emit('message', Buffer.from(text, 'utf8'));
267
+ }
268
+ } else {
269
+ this.emit('error', action.error);
270
+ }
271
+ });
272
+ }
273
+ }
274
+ }
275
+
276
+ close(): void {
277
+ this.readyState = 3;
278
+ if (fakeErrorOnClose) {
279
+ this.emit('error', new Error('emitted on close'));
280
+ }
281
+ }
282
+ }
283
+
284
+ /**
285
+ * A WebSocketCtor whose constructor throws synchronously — drives the
286
+ * `this.ws === undefined` path in teardown.
287
+ */
288
+ class ThrowingSocketCtor extends EventEmitter {
289
+ constructor(_url: string, _protocols: string[]) {
290
+ super();
291
+ throw new Error('constructor blew up');
292
+ }
293
+ }
294
+
295
+ describe('IrcLoadClient — failure paths (fake WebSocket)', () => {
296
+ beforeEach(() => {
297
+ fakeAutoOpen = true;
298
+ fakeHandlers = [];
299
+ fakeReadyState = undefined;
300
+ fakeErrorOnClose = false;
301
+ lastFake = undefined;
302
+ });
303
+
304
+ /** Resolves a config aimed at the fake, with short timeouts. */
305
+ function fakeCfg(
306
+ overrides: Parameters<typeof resolveConfig>[0] = {},
307
+ ): ReturnType<typeof resolveConfig> {
308
+ return resolveConfig({
309
+ target: 'ws://fake.local/',
310
+ channel: '#test',
311
+ nickPrefix: 'bot',
312
+ messagesPerClient: 0,
313
+ connectTimeoutMs: 1000,
314
+ registerTimeoutMs: 100,
315
+ ...overrides,
316
+ });
317
+ }
318
+
319
+ it('fails at connect when the socket never opens (connect timeout)', async () => {
320
+ fakeAutoOpen = false;
321
+ const client = new IrcLoadClient({
322
+ id: 0,
323
+ config: fakeCfg({ connectTimeoutMs: 50 }),
324
+ WebSocketCtor: FakeSocket as unknown as WebSocketCtor,
325
+ });
326
+ const result = await client.run();
327
+ expect(result.ok).toBe(false);
328
+ expect(result.failedPhase).toBe('connect');
329
+ expect(result.error).toMatch(/connect timeout/i);
330
+ });
331
+
332
+ it('fails at join when the server never replies with 366', async () => {
333
+ // Registration completes (001 sent in reply to NICK); JOIN gets no 366
334
+ // so the join waiter times out. The reply bundles a blank line and a
335
+ // non-prefixed PING line alongside the 001 to also cover the empty-line
336
+ // skip (onMessage) and the non-':' prefix arm (numericOf).
337
+ fakeHandlers = [
338
+ {
339
+ match: /^NICK /u,
340
+ action: {
341
+ kind: 'messages',
342
+ lines: ['\r\nPING :keepalive\r\n:srv 001 bot0 :Welcome'],
343
+ },
344
+ },
345
+ ];
346
+ const client = new IrcLoadClient({
347
+ id: 0,
348
+ config: fakeCfg({ echoMessage: false }),
349
+ WebSocketCtor: FakeSocket as unknown as WebSocketCtor,
350
+ });
351
+ const result = await client.run();
352
+ expect(result.ok).toBe(false);
353
+ expect(result.failedPhase).toBe('join');
354
+ expect(result.samples.register.length).toBe(1);
355
+ expect(lastFake?.sent.some((l) => l.startsWith('JOIN'))).toBe(true);
356
+ });
357
+
358
+ it('fails at privmsg when the echo never arrives', async () => {
359
+ // Registration + join succeed; the PRIVMSG echo never arrives so the
360
+ // privmsg waiter times out.
361
+ fakeHandlers = [
362
+ {
363
+ match: /^NICK /u,
364
+ action: { kind: 'messages', lines: [':srv 001 bot0 :Welcome'] },
365
+ },
366
+ {
367
+ match: /^JOIN /u,
368
+ action: { kind: 'messages', lines: [':srv 366 bot0 #test :End of NAMES'] },
369
+ },
370
+ ];
371
+ const client = new IrcLoadClient({
372
+ id: 0,
373
+ config: fakeCfg({ messagesPerClient: 1, echoMessage: false }),
374
+ WebSocketCtor: FakeSocket as unknown as WebSocketCtor,
375
+ });
376
+ const result = await client.run();
377
+ expect(result.ok).toBe(false);
378
+ expect(result.failedPhase).toBe('privmsg');
379
+ expect(result.samples.register.length).toBe(1);
380
+ expect(result.samples.join.length).toBe(1);
381
+ expect(lastFake?.sent.some((l) => l.startsWith('PRIVMSG'))).toBe(true);
382
+ });
383
+
384
+ it('fails via onError when the socket errors during a pending wait', async () => {
385
+ // The socket errors mid-registration: the `on('error')` handler fans the
386
+ // error out to the pending register waiter via failWaiters.
387
+ fakeHandlers = [
388
+ {
389
+ match: /^NICK /u,
390
+ action: { kind: 'error', error: new Error('socket boom') },
391
+ },
392
+ ];
393
+ const client = new IrcLoadClient({
394
+ id: 0,
395
+ config: fakeCfg({ echoMessage: false }),
396
+ WebSocketCtor: FakeSocket as unknown as WebSocketCtor,
397
+ });
398
+ const result = await client.run();
399
+ expect(result.ok).toBe(false);
400
+ expect(result.error).toBe('socket boom');
401
+ // connect succeeded before the error fired.
402
+ expect(result.samples.connect.length).toBe(1);
403
+ });
404
+
405
+ it('coerces a non-Error rejection to a string error message', async () => {
406
+ // Some transports emit non-Error values on 'error'. The client's failedAt
407
+ // helper must fall back to String(err) rather than crash.
408
+ fakeHandlers = [
409
+ {
410
+ match: /^NICK /u,
411
+ action: { kind: 'error', error: 'stringy failure' },
412
+ },
413
+ ];
414
+ const client = new IrcLoadClient({
415
+ id: 0,
416
+ config: fakeCfg({ echoMessage: false }),
417
+ WebSocketCtor: FakeSocket as unknown as WebSocketCtor,
418
+ });
419
+ const result = await client.run();
420
+ expect(result.ok).toBe(false);
421
+ expect(result.error).toBe('stringy failure');
422
+ });
423
+
424
+ it('skips send() when the socket is not in the OPEN state', async () => {
425
+ // The fake reports CLOSED even though it emits 'open', so the send()
426
+ // readyState guard is exercised and no lines reach the wire.
427
+ fakeReadyState = 3;
428
+ const client = new IrcLoadClient({
429
+ id: 0,
430
+ config: fakeCfg({ echoMessage: false }),
431
+ WebSocketCtor: FakeSocket as unknown as WebSocketCtor,
432
+ });
433
+ const result = await client.run();
434
+ expect(result.ok).toBe(false);
435
+ expect(result.failedPhase).toBe('register');
436
+ expect(lastFake?.sent.length).toBe(0);
437
+ });
438
+
439
+ it('ignores a socket error that arrives after teardown', async () => {
440
+ // The fake emits 'error' from close(); by the time teardown calls close()
441
+ // the client is already settled, so onError's guard short-circuits.
442
+ fakeErrorOnClose = true;
443
+ const client = new IrcLoadClient({
444
+ id: 0,
445
+ config: fakeCfg({ echoMessage: false }),
446
+ WebSocketCtor: FakeSocket as unknown as WebSocketCtor,
447
+ });
448
+ // register will time out (no 001), then teardown → close() → error is
449
+ // swallowed by the settled guard.
450
+ const result = await client.run();
451
+ expect(result.ok).toBe(false);
452
+ expect(result.failedPhase).toBe('register');
453
+ });
454
+
455
+ it('handles a WebSocket constructor that throws synchronously', async () => {
456
+ // The constructor blowing up means this.ws is never assigned; teardown
457
+ // must skip the close path without crashing.
458
+ const client = new IrcLoadClient({
459
+ id: 0,
460
+ config: fakeCfg({ echoMessage: false }),
461
+ WebSocketCtor: ThrowingSocketCtor as unknown as WebSocketCtor,
462
+ });
463
+ const result = await client.run();
464
+ expect(result.ok).toBe(false);
465
+ expect(result.failedPhase).toBe('connect');
466
+ expect(result.error).toBe('constructor blew up');
467
+ });
468
+ });
@@ -69,6 +69,35 @@ describe('resolveConfig — validation (fail-fast)', () => {
69
69
  it('throws when the channel does not start with #', () => {
70
70
  expect(() => resolveConfig({ target: 'ws://x/', channel: 'loadtest' })).toThrow(/channel/i);
71
71
  });
72
+
73
+ it('throws when connectTimeoutMs is non-positive', () => {
74
+ expect(() => resolveConfig({ target: 'ws://x/', connectTimeoutMs: 0 })).toThrow(
75
+ /connect timeout/i,
76
+ );
77
+ });
78
+
79
+ it('throws when registerTimeoutMs is non-positive', () => {
80
+ expect(() => resolveConfig({ target: 'ws://x/', registerTimeoutMs: -1 })).toThrow(
81
+ /register timeout/i,
82
+ );
83
+ });
84
+
85
+ it('throws when rampMs is negative', () => {
86
+ expect(() => resolveConfig({ target: 'ws://x/', rampMs: -5 })).toThrow(/ramp-ms/i);
87
+ });
88
+
89
+ it('throws when messageIntervalMs is negative', () => {
90
+ expect(() => resolveConfig({ target: 'ws://x/', messageIntervalMs: -1 })).toThrow(
91
+ /message interval/i,
92
+ );
93
+ });
94
+ });
95
+
96
+ describe('resolveConfig — reportPath propagation', () => {
97
+ it('sets reportPath when provided', () => {
98
+ const cfg = resolveConfig({ target: 'ws://x/', reportPath: './out/report.md' });
99
+ expect(cfg.reportPath).toBe('./out/report.md');
100
+ });
72
101
  });
73
102
 
74
103
  describe('parseArgs — CLI flag parsing', () => {
@@ -107,6 +136,16 @@ describe('parseArgs — CLI flag parsing', () => {
107
136
  expect(() => parseArgs(['--target', 'ws://x/', '--bogus', '1'])).toThrow(/unknown argument/i);
108
137
  });
109
138
 
139
+ it('skips sparse (hole) entries in the argv array', () => {
140
+ // A hole at index 0 exercises the `a === undefined` guard in the parse
141
+ // loop; the remaining flags still resolve to a valid config.
142
+ const sparse: string[] = [];
143
+ sparse[1] = '--target';
144
+ sparse[2] = 'ws://x/';
145
+ const cfg = parseArgs(sparse);
146
+ expect(cfg.target).toBe('ws://x/');
147
+ });
148
+
110
149
  it('throws when --target is absent and no positional is given', () => {
111
150
  expect(() => parseArgs([])).toThrow(/target/i);
112
151
  });
@@ -81,6 +81,27 @@ describe('runLoadTest — aggregation', () => {
81
81
  const md = generateReport(run);
82
82
  expect(md).toContain('Latency');
83
83
  });
84
+
85
+ it('invokes onProgress as each client completes', async () => {
86
+ const calls: Array<{ done: number; total: number }> = [];
87
+ const factory = () => ({
88
+ run: async () =>
89
+ ({
90
+ id: 0,
91
+ ok: true,
92
+ samples: { connect: [1], register: [], join: [], privmsg: [] },
93
+ }) as ClientResult,
94
+ });
95
+ const run = await runLoadTest(cfg({ connections: 4, connectConcurrency: 2 }), {
96
+ clientFactory: factory,
97
+ onProgress: (done, total) => calls.push({ done, total }),
98
+ });
99
+ expect(run.summary.totalClients).toBe(4);
100
+ // Monotonic done counter, one call per client, total stays constant.
101
+ expect(calls.length).toBe(4);
102
+ expect(calls.every((c) => c.total === 4)).toBe(true);
103
+ expect(calls.map((c) => c.done)).toEqual([1, 2, 3, 4]);
104
+ });
84
105
  });
85
106
 
86
107
  describe('runLoadTest — concurrency cap', () => {
@@ -59,6 +59,13 @@ describe('percentile (nearest-rank)', () => {
59
59
  it('throws for a percentile above 100 (programming error)', () => {
60
60
  expect(() => percentile([1, 2, 3], 101)).toThrow(/percentile/i);
61
61
  });
62
+
63
+ it('returns 0 for a NaN percentile (slips the range guard, hits the defensive fallback)', () => {
64
+ // NaN < 0 and NaN > 100 are both false, so NaN passes the range check.
65
+ // It then makes the index NaN, so sorted[NaN] is undefined and the
66
+ // `?? 0` fallback returns 0 rather than NaN.
67
+ expect(percentile([1, 2, 3], Number.NaN)).toBe(0);
68
+ });
62
69
  });
63
70
 
64
71
  describe('summarizePhase', () => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/tcp-ws-forwarder",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Local TCP to ws/wss forwarder so standard IRC clients can reach the WebSocket-only ServerlessIRCd transport",
6
6
  "license": "BSD-3-Clause",
@@ -0,0 +1,40 @@
1
+ import type { Socket } from 'node:net';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ /**
5
+ * Dedicated coverage for the `server.close` error-rejection branch in
6
+ * forwarder close() (forwarder.ts:135): `server.close((err) => err ?
7
+ * reject(err) : resolve())`. Under normal operation the callback never
8
+ * surfaces an error, so the truthy branch is unreachable without a stub.
9
+ *
10
+ * This file mocks `node:net` so the forwarder's TCP listener gets a server
11
+ * whose `close` invokes its callback with a synthetic Error. The mock is
12
+ * file-scoped (vi.mock), leaving the real-socket integration suite in
13
+ * forwarder.test.ts untouched.
14
+ */
15
+
16
+ vi.mock('node:net', async (importActual) => {
17
+ const actual = await importActual<typeof import('node:net')>();
18
+ const wrappedCreateServer = (connectionListener?: (socket: Socket) => void) => {
19
+ const server = actual.createServer(connectionListener);
20
+ const realClose = server.close.bind(server);
21
+ // Drive the real shutdown so the listener is actually torn down, but
22
+ // surface a synthetic error to the callback to exercise the
23
+ // error-rejection branch.
24
+ server.close = ((cb?: (err?: Error | null) => void) => {
25
+ realClose(() => cb?.(new Error('synthetic server.close failure')));
26
+ return server;
27
+ }) as unknown as typeof server.close;
28
+ return server;
29
+ };
30
+ return { ...actual, createServer: wrappedCreateServer as unknown as typeof actual.createServer };
31
+ });
32
+
33
+ const { startForwarder } = await import('../src/forwarder');
34
+
35
+ describe('forwarder close() — server.close error branch', () => {
36
+ it('rejects when server.close surfaces an error to its callback', async () => {
37
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: 'ws://127.0.0.1:1/' });
38
+ await expect(fwd.close()).rejects.toThrow('synthetic server.close failure');
39
+ });
40
+ });
@@ -0,0 +1,78 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { Socket } from 'node:net';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ /**
6
+ * Coverage for two defensive branches in forwarder.ts that cannot be reached
7
+ * through real sockets:
8
+ * - line 148: `server.address()` always returns an AddressInfo after the
9
+ * 'listening' event fires, so the `opts.listenPort` ternary fallback is
10
+ * never taken in practice.
11
+ * - line 184: a connected server-side socket always carries remoteAddress/
12
+ * remotePort, so the `?? '?'` remote-label fallbacks are never taken.
13
+ *
14
+ * Both are exercised here by mocking `node:net` so the forwarder's TCP server
15
+ * reports a null address and its connection listener is captured, allowing a
16
+ * synthetic address-less socket to be fed in. The mock is file-scoped
17
+ * (vi.mock); the real-socket integration suite in forwarder.test.ts is
18
+ * untouched.
19
+ */
20
+
21
+ const state = vi.hoisted(() => ({
22
+ captured: undefined as ((socket: Socket) => void) | undefined,
23
+ }));
24
+
25
+ vi.mock('node:net', async (importActual) => {
26
+ const actual = await importActual<typeof import('node:net')>();
27
+ const wrappedCreateServer = (connectionListener?: (socket: Socket) => void) => {
28
+ state.captured = connectionListener;
29
+ const server = actual.createServer(connectionListener);
30
+ // Force the defensive AddressInfo fallback (forwarder.ts:148).
31
+ server.address = (() => null) as typeof server.address;
32
+ return server;
33
+ };
34
+ return {
35
+ ...actual,
36
+ createServer: wrappedCreateServer as unknown as typeof actual.createServer,
37
+ };
38
+ });
39
+
40
+ const { startForwarder } = await import('../src/forwarder');
41
+
42
+ const openForwarders: { close(): Promise<void> }[] = [];
43
+ afterEach(async () => {
44
+ for (const f of openForwarders.splice(0)) {
45
+ await f.close().catch(() => undefined);
46
+ }
47
+ });
48
+
49
+ describe('forwarder — defensive branches unreachable via real sockets', () => {
50
+ it('falls back to opts.listenPort when server.address() yields no AddressInfo', async () => {
51
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: 'ws://127.0.0.1:1/' });
52
+ openForwarders.push(fwd);
53
+ // server.address() returns null → the ternary takes its false branch and
54
+ // the reported port is the requested opts.listenPort (0), not a real
55
+ // ephemeral port.
56
+ expect(fwd.listenPort).toBe(0);
57
+ });
58
+
59
+ it('builds the remote label with "?" placeholders for an address-less socket', async () => {
60
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: 'ws://127.0.0.1:1/' });
61
+ openForwarders.push(fwd);
62
+
63
+ // Synthesize an incoming connection on a socket with no remote address/
64
+ // port; bridgeConnection must build the `?:?` label (forwarder.ts:184)
65
+ // and register the bridge without throwing.
66
+ const fakeSocket = new EventEmitter();
67
+ Object.assign(fakeSocket, {
68
+ remoteAddress: undefined,
69
+ remotePort: undefined,
70
+ destroy: () => undefined,
71
+ });
72
+ const listener = state.captured;
73
+ if (listener === undefined) throw new Error('connection listener was not captured');
74
+ listener(fakeSocket as unknown as Socket);
75
+
76
+ expect([...fwd.testSockets]).toContain(fakeSocket);
77
+ });
78
+ });
@@ -735,3 +735,54 @@ describe('forwarder — logging', () => {
735
735
  infoSpy.mockRestore();
736
736
  });
737
737
  });
738
+
739
+ describe('forwarder — empty-line drop (TCP→WS guard)', () => {
740
+ it('drops a bare-CRLF empty line and forwards only the next non-empty line', async () => {
741
+ const target = await startTarget();
742
+ openServers.push(target);
743
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
744
+ openServers.push(fwd);
745
+
746
+ const client = new TcpClient(fwd.listenPort);
747
+ openClients.push(client);
748
+ await client.opened();
749
+
750
+ // A bare CRLF yields an empty line from the LineScanner; the
751
+ // `line.length > 0` guard drops it, so only the subsequent real line is
752
+ // bridged as a single WS frame.
753
+ client.sendRaw('\r\n');
754
+ client.send('PING :after-empty');
755
+
756
+ const ws = await target.waitForConnection();
757
+ await new Promise<void>((res) => ws.once('message', () => res()));
758
+ expect(target.frames).toEqual(['PING :after-empty']);
759
+ });
760
+ });
761
+
762
+ describe('forwarder — post-teardown error suppression', () => {
763
+ it('suppresses a tcp socket error arriving after the bridge has torn down', async () => {
764
+ const target = await startTarget();
765
+ openServers.push(target);
766
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
767
+ openServers.push(fwd);
768
+
769
+ const client = new TcpClient(fwd.listenPort);
770
+ openClients.push(client);
771
+ await client.opened();
772
+ await target.waitForConnection();
773
+
774
+ const serverSide = [...fwd.testSockets][0];
775
+ if (serverSide === undefined) throw new Error('no server-side socket');
776
+
777
+ // Tearing the bridge down arms the `bridgeClosed` guard; a tcp error
778
+ // arriving afterwards (e.g. an RST echoing our own destroy()) is
779
+ // expected and must be silently suppressed.
780
+ await fwd.close();
781
+
782
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
783
+ serverSide.emit('error', new Error('post-teardown noise'));
784
+ await new Promise((r) => setImmediate(r));
785
+ expect(errSpy).not.toHaveBeenCalled();
786
+ errSpy.mockRestore();
787
+ });
788
+ });
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import { ConsoleLogger, LogLevel, parseLogLevel } from '../src/logger';
3
3
 
4
4
  /**
@@ -134,3 +134,33 @@ describe('ConsoleLogger — output shape', () => {
134
134
  expect(JSON.parse(lines.error[0] as string).msg).toBe('e');
135
135
  });
136
136
  });
137
+
138
+ describe('ConsoleLogger — global console sinks', () => {
139
+ it('routes a warn record to the global console.warn sink when no sinks are injected', () => {
140
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
141
+ const log = new ConsoleLogger(undefined, LogLevel.Warn);
142
+ log.warn('global sink warn', { k: 'v' });
143
+ expect(warnSpy).toHaveBeenCalledTimes(1);
144
+ const rec = JSON.parse(String(warnSpy.mock.calls[0]?.[0] ?? '{}'));
145
+ expect(rec.msg).toBe('global sink warn');
146
+ warnSpy.mockRestore();
147
+ });
148
+
149
+ it('falls back to no-op drop-sinks when globalThis.console is undefined', () => {
150
+ const original = globalThis.console;
151
+ (globalThis as { console?: Console | undefined }).console = undefined;
152
+ try {
153
+ // globalConsoleSinks() takes the `console === undefined` branch and
154
+ // returns drop-sinks; every level must swallow without throwing.
155
+ const log = new ConsoleLogger(undefined, LogLevel.Debug);
156
+ expect(() => {
157
+ log.debug('d');
158
+ log.info('i');
159
+ log.warn('w');
160
+ log.error('e');
161
+ }).not.toThrow();
162
+ } finally {
163
+ (globalThis as { console?: Console | undefined }).console = original;
164
+ }
165
+ });
166
+ });