serverless-ircd 0.1.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.
- package/.github/workflows/ci.yml +47 -0
- package/.github/workflows/deploy-cf.yml +97 -0
- package/LICENSE +29 -0
- package/README.md +323 -0
- package/apps/cf-worker/package.json +37 -0
- package/apps/cf-worker/scripts/smoke.mjs +175 -0
- package/apps/cf-worker/src/worker.ts +59 -0
- package/apps/cf-worker/tests/smoke.test.ts +150 -0
- package/apps/cf-worker/tsconfig.build.json +17 -0
- package/apps/cf-worker/tsconfig.test.json +18 -0
- package/apps/cf-worker/vitest.config.ts +31 -0
- package/apps/cf-worker/wrangler.test.toml +33 -0
- package/apps/cf-worker/wrangler.toml +98 -0
- package/apps/local-cli/package.json +35 -0
- package/apps/local-cli/src/line-scanner.ts +60 -0
- package/apps/local-cli/src/main.ts +145 -0
- package/apps/local-cli/src/motd-file.ts +45 -0
- package/apps/local-cli/src/server.ts +409 -0
- package/apps/local-cli/tests/e2e.test.ts +346 -0
- package/apps/local-cli/tests/id-factory.test.ts +25 -0
- package/apps/local-cli/tests/line-scanner.test.ts +81 -0
- package/apps/local-cli/tests/motd-file.test.ts +71 -0
- package/apps/local-cli/tests/tcp.test.ts +358 -0
- package/apps/local-cli/tsconfig.build.json +17 -0
- package/apps/local-cli/tsconfig.test.json +15 -0
- package/apps/local-cli/vitest.config.ts +24 -0
- package/biome.json +52 -0
- package/package.json +35 -0
- package/packages/cf-adapter/package.json +38 -0
- package/packages/cf-adapter/src/cf-runtime.ts +308 -0
- package/packages/cf-adapter/src/channel-do.ts +374 -0
- package/packages/cf-adapter/src/connection-do.ts +542 -0
- package/packages/cf-adapter/src/env.ts +67 -0
- package/packages/cf-adapter/src/index.ts +38 -0
- package/packages/cf-adapter/src/registry-do.ts +130 -0
- package/packages/cf-adapter/src/serialize.ts +142 -0
- package/packages/cf-adapter/src/sharding.ts +101 -0
- package/packages/cf-adapter/tests/cf-harness.ts +264 -0
- package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
- package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
- package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
- package/packages/cf-adapter/tests/integration/harness.ts +102 -0
- package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
- package/packages/cf-adapter/tests/integration/index.ts +17 -0
- package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
- package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
- package/packages/cf-adapter/tests/sharding.test.ts +100 -0
- package/packages/cf-adapter/tests/worker/main.ts +33 -0
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
- package/packages/cf-adapter/tsconfig.build.json +16 -0
- package/packages/cf-adapter/tsconfig.test.json +18 -0
- package/packages/cf-adapter/vitest.config.ts +32 -0
- package/packages/cf-adapter/wrangler.test.toml +50 -0
- package/packages/in-memory-runtime/package.json +29 -0
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
- package/packages/in-memory-runtime/src/index.ts +9 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
- package/packages/in-memory-runtime/tsconfig.build.json +16 -0
- package/packages/in-memory-runtime/tsconfig.test.json +14 -0
- package/packages/in-memory-runtime/vitest.config.ts +21 -0
- package/packages/irc-core/package.json +29 -0
- package/packages/irc-core/src/caps/capabilities.ts +96 -0
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/commands/away.ts +82 -0
- package/packages/irc-core/src/commands/cap.ts +257 -0
- package/packages/irc-core/src/commands/chghost.ts +30 -0
- package/packages/irc-core/src/commands/index.ts +40 -0
- package/packages/irc-core/src/commands/invite.ts +156 -0
- package/packages/irc-core/src/commands/isupport.ts +133 -0
- package/packages/irc-core/src/commands/join.ts +309 -0
- package/packages/irc-core/src/commands/kick.ts +162 -0
- package/packages/irc-core/src/commands/list.ts +126 -0
- package/packages/irc-core/src/commands/mode.ts +655 -0
- package/packages/irc-core/src/commands/motd.ts +146 -0
- package/packages/irc-core/src/commands/names.ts +164 -0
- package/packages/irc-core/src/commands/part.ts +118 -0
- package/packages/irc-core/src/commands/ping.ts +47 -0
- package/packages/irc-core/src/commands/privmsg.ts +256 -0
- package/packages/irc-core/src/commands/quit.ts +70 -0
- package/packages/irc-core/src/commands/registration.ts +251 -0
- package/packages/irc-core/src/commands/topic.ts +171 -0
- package/packages/irc-core/src/commands/who.ts +169 -0
- package/packages/irc-core/src/commands/whois.ts +165 -0
- package/packages/irc-core/src/effects.ts +184 -0
- package/packages/irc-core/src/index.ts +20 -0
- package/packages/irc-core/src/ports.ts +153 -0
- package/packages/irc-core/src/protocol/batch.ts +85 -0
- package/packages/irc-core/src/protocol/index.ts +18 -0
- package/packages/irc-core/src/protocol/messages.ts +36 -0
- package/packages/irc-core/src/protocol/numerics.ts +155 -0
- package/packages/irc-core/src/protocol/outbound.ts +145 -0
- package/packages/irc-core/src/protocol/parser.ts +175 -0
- package/packages/irc-core/src/protocol/serializer.ts +71 -0
- package/packages/irc-core/src/state/channel.ts +293 -0
- package/packages/irc-core/src/state/connection.ts +153 -0
- package/packages/irc-core/src/state/index.ts +25 -0
- package/packages/irc-core/src/state/registry.ts +22 -0
- package/packages/irc-core/src/types.ts +83 -0
- package/packages/irc-core/tests/batch.test.ts +79 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
- package/packages/irc-core/tests/commands/away.test.ts +233 -0
- package/packages/irc-core/tests/commands/batch.test.ts +178 -0
- package/packages/irc-core/tests/commands/cap.test.ts +499 -0
- package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
- package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
- package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
- package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
- package/packages/irc-core/tests/commands/invite.test.ts +321 -0
- package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
- package/packages/irc-core/tests/commands/join.test.ts +687 -0
- package/packages/irc-core/tests/commands/kick.test.ts +316 -0
- package/packages/irc-core/tests/commands/list.test.ts +452 -0
- package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
- package/packages/irc-core/tests/commands/motd.test.ts +322 -0
- package/packages/irc-core/tests/commands/names.test.ts +342 -0
- package/packages/irc-core/tests/commands/part.test.ts +265 -0
- package/packages/irc-core/tests/commands/ping.test.ts +144 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
- package/packages/irc-core/tests/commands/quit.test.ts +220 -0
- package/packages/irc-core/tests/commands/registration.test.ts +599 -0
- package/packages/irc-core/tests/commands/topic.test.ts +337 -0
- package/packages/irc-core/tests/commands/who.test.ts +441 -0
- package/packages/irc-core/tests/commands/whois.test.ts +422 -0
- package/packages/irc-core/tests/effects.test.ts +147 -0
- package/packages/irc-core/tests/message-tags.test.ts +182 -0
- package/packages/irc-core/tests/numerics.test.ts +98 -0
- package/packages/irc-core/tests/outbound.test.ts +232 -0
- package/packages/irc-core/tests/parser.test.ts +162 -0
- package/packages/irc-core/tests/ports.test.ts +101 -0
- package/packages/irc-core/tests/serializer.test.ts +164 -0
- package/packages/irc-core/tests/state/channel.test.ts +351 -0
- package/packages/irc-core/tests/state/connection.test.ts +122 -0
- package/packages/irc-core/tests/state/registry.test.ts +20 -0
- package/packages/irc-core/tests/types.test.ts +127 -0
- package/packages/irc-core/tsconfig.build.json +12 -0
- package/packages/irc-core/tsconfig.test.json +10 -0
- package/packages/irc-core/vitest.config.ts +21 -0
- package/packages/irc-server/package.json +28 -0
- package/packages/irc-server/src/actor.ts +449 -0
- package/packages/irc-server/src/dispatch.ts +120 -0
- package/packages/irc-server/src/index.ts +11 -0
- package/packages/irc-server/src/runtime.ts +61 -0
- package/packages/irc-server/tests/actor.test.ts +816 -0
- package/packages/irc-server/tests/dispatch.test.ts +512 -0
- package/packages/irc-server/tests/runtime.test.ts +52 -0
- package/packages/irc-server/tsconfig.build.json +13 -0
- package/packages/irc-server/tsconfig.test.json +11 -0
- package/packages/irc-server/vitest.config.ts +21 -0
- package/pnpm-workspace.yaml +4 -0
- package/tools/tcp-ws-forwarder/package.json +32 -0
- package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
- package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
- package/tools/tcp-ws-forwarder/src/main.ts +114 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
- package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
- package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
- package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
- package/tsconfig.base.json +25 -0
- package/turbo.json +29 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
type Clock,
|
|
4
|
+
FakeClock,
|
|
5
|
+
type IdFactory,
|
|
6
|
+
SequentialIdFactory,
|
|
7
|
+
SystemClock,
|
|
8
|
+
UuidIdFactory,
|
|
9
|
+
} from '../src/ports';
|
|
10
|
+
|
|
11
|
+
describe('SystemClock', () => {
|
|
12
|
+
it('returns a non-decreasing epoch millisecond value', () => {
|
|
13
|
+
const clock: Clock = SystemClock;
|
|
14
|
+
const a = clock.now();
|
|
15
|
+
const b = clock.now();
|
|
16
|
+
expect(b).toBeGreaterThanOrEqual(a);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('FakeClock', () => {
|
|
21
|
+
it('starts at zero by default', () => {
|
|
22
|
+
const clock = new FakeClock();
|
|
23
|
+
expect(clock.now()).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('starts at the supplied start value', () => {
|
|
27
|
+
const clock = new FakeClock(1_000);
|
|
28
|
+
expect(clock.now()).toBe(1_000);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('implements the Clock port', () => {
|
|
32
|
+
const clock: Clock = new FakeClock();
|
|
33
|
+
expect(clock.now()).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('advance increases the current time', () => {
|
|
37
|
+
const clock = new FakeClock(100);
|
|
38
|
+
clock.advance(50);
|
|
39
|
+
expect(clock.now()).toBe(150);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('set replaces the current time', () => {
|
|
43
|
+
const clock = new FakeClock(100);
|
|
44
|
+
clock.set(1_000);
|
|
45
|
+
expect(clock.now()).toBe(1_000);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('UuidIdFactory', () => {
|
|
50
|
+
it('implements the IdFactory port', () => {
|
|
51
|
+
const f: IdFactory = new UuidIdFactory();
|
|
52
|
+
expect(typeof f.batchId()).toBe('string');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns unique values for batchId', () => {
|
|
56
|
+
const f = new UuidIdFactory();
|
|
57
|
+
expect(f.batchId()).not.toBe(f.batchId());
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns unique values for nonce', () => {
|
|
61
|
+
const f = new UuidIdFactory();
|
|
62
|
+
expect(f.nonce()).not.toBe(f.nonce());
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns unique values for sessionId', () => {
|
|
66
|
+
const f = new UuidIdFactory();
|
|
67
|
+
expect(f.sessionId()).not.toBe(f.sessionId());
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('SequentialIdFactory', () => {
|
|
72
|
+
it('implements the IdFactory port', () => {
|
|
73
|
+
const f: IdFactory = new SequentialIdFactory();
|
|
74
|
+
expect(typeof f.batchId()).toBe('string');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns deterministic sequential batch ids', () => {
|
|
78
|
+
const f = new SequentialIdFactory();
|
|
79
|
+
expect(f.batchId()).toBe('batch-0');
|
|
80
|
+
expect(f.batchId()).toBe('batch-1');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns deterministic sequential nonces', () => {
|
|
84
|
+
const f = new SequentialIdFactory();
|
|
85
|
+
expect(f.nonce()).toBe('nonce-0');
|
|
86
|
+
expect(f.nonce()).toBe('nonce-1');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns deterministic sequential session ids', () => {
|
|
90
|
+
const f = new SequentialIdFactory();
|
|
91
|
+
expect(f.sessionId()).toBe('session-0');
|
|
92
|
+
expect(f.sessionId()).toBe('session-1');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('shares one counter across methods', () => {
|
|
96
|
+
const f = new SequentialIdFactory();
|
|
97
|
+
expect(f.batchId()).toBe('batch-0');
|
|
98
|
+
expect(f.nonce()).toBe('nonce-1');
|
|
99
|
+
expect(f.sessionId()).toBe('session-2');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import fc from 'fast-check';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import type { IrcMessage, IrcSource } from '../src/protocol/messages';
|
|
4
|
+
import { parse } from '../src/protocol/parser';
|
|
5
|
+
import { serialize, serializeFrame } from '../src/protocol/serializer';
|
|
6
|
+
|
|
7
|
+
describe('serialize — golden vectors', () => {
|
|
8
|
+
it('emits the trailing colon for a parameter containing a space', () => {
|
|
9
|
+
expect(serialize({ tags: {}, command: 'PRIVMSG', params: ['#foo', 'hello world'] })).toBe(
|
|
10
|
+
'PRIVMSG #foo :hello world',
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('emits a full nick!user@host source', () => {
|
|
15
|
+
expect(
|
|
16
|
+
serialize({
|
|
17
|
+
tags: {},
|
|
18
|
+
source: { name: 'nick', user: 'user', host: 'host' },
|
|
19
|
+
command: 'PRIVMSG',
|
|
20
|
+
params: ['#foo', 'hi'],
|
|
21
|
+
}),
|
|
22
|
+
).toBe(':nick!user@host PRIVMSG #foo :hi');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('emits a server source (name only)', () => {
|
|
26
|
+
expect(
|
|
27
|
+
serialize({
|
|
28
|
+
tags: {},
|
|
29
|
+
source: { name: 'irc.example.com' },
|
|
30
|
+
command: 'NOTICE',
|
|
31
|
+
params: ['*', 'hi'],
|
|
32
|
+
}),
|
|
33
|
+
).toBe(':irc.example.com NOTICE * :hi');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('emits an empty trailing parameter as a dangling colon', () => {
|
|
37
|
+
expect(serialize({ tags: {}, command: 'TOPIC', params: ['#foo', ''] })).toBe('TOPIC #foo :');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('forces trailing for a parameter that starts with a colon (doubled colon)', () => {
|
|
41
|
+
expect(serialize({ tags: {}, command: 'PRIVMSG', params: ['#foo', ':x'] })).toBe(
|
|
42
|
+
'PRIVMSG #foo ::x',
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('uses the trailing colon even for a single safe parameter', () => {
|
|
47
|
+
expect(serialize({ tags: {}, command: 'JOIN', params: ['#foo'] })).toBe('JOIN :#foo');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('emits just the command when there are no parameters', () => {
|
|
51
|
+
expect(serialize({ tags: {}, command: 'PONG', params: [] })).toBe('PONG');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('preserves a numeric command verbatim', () => {
|
|
55
|
+
expect(serialize({ tags: {}, command: '001', params: ['nick', 'Welcome'] })).toBe(
|
|
56
|
+
'001 nick :Welcome',
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('serialize — message-tags (IRCv3)', () => {
|
|
62
|
+
it('emits valued tags before the rest of the message', () => {
|
|
63
|
+
expect(
|
|
64
|
+
serialize({
|
|
65
|
+
tags: { time: 't', account: 'a' },
|
|
66
|
+
command: 'PRIVMSG',
|
|
67
|
+
params: ['#foo', 'hi'],
|
|
68
|
+
}),
|
|
69
|
+
).toBe('@time=t;account=a PRIVMSG #foo :hi');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('omits the equals sign for a valueless tag', () => {
|
|
73
|
+
expect(serialize({ tags: { '+away': '' }, command: 'PRIVMSG', params: ['#foo', 'hi'] })).toBe(
|
|
74
|
+
'@+away PRIVMSG #foo :hi',
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('escapes a semicolon in a tag value', () => {
|
|
79
|
+
expect(serialize({ tags: { a: 'a;b' }, command: 'PING', params: [] })).toBe('@a=a\\:b PING');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('escapes a space in a tag value', () => {
|
|
83
|
+
expect(serialize({ tags: { a: 'a b' }, command: 'PING', params: [] })).toBe('@a=a\\sb PING');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('escapes a backslash in a tag value', () => {
|
|
87
|
+
expect(serialize({ tags: { a: 'a\\b' }, command: 'PING', params: [] })).toBe('@a=a\\\\b PING');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('emits tags, source and body together', () => {
|
|
91
|
+
expect(
|
|
92
|
+
serialize({
|
|
93
|
+
tags: { account: 'foo' },
|
|
94
|
+
source: { name: 'nick', user: 'u', host: 'h' },
|
|
95
|
+
command: 'PRIVMSG',
|
|
96
|
+
params: ['#foo', 'hi'],
|
|
97
|
+
}),
|
|
98
|
+
).toBe('@account=foo :nick!u@h PRIVMSG #foo :hi');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('serializeFrame', () => {
|
|
103
|
+
it('appends CR LF to a serialized message', () => {
|
|
104
|
+
expect(serializeFrame({ tags: {}, command: 'PING', params: ['tok'] })).toBe('PING :tok\r\n');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('serialize <-> parse round-trip (property test)', () => {
|
|
109
|
+
const safeMiddle = fc.stringMatching(/^[A-Za-z0-9._+\-]{1,8}$/);
|
|
110
|
+
const freeTrailing = fc.stringMatching(/^[A-Za-z0-9._+\- :;\\]{0,24}$/);
|
|
111
|
+
const commandArb = fc.oneof(fc.stringMatching(/^[A-Z]{1,8}$/), fc.stringMatching(/^[0-9]{3}$/));
|
|
112
|
+
const tagKey = fc.stringMatching(/^[a-z0-9][a-z0-9/+\\-]{0,14}$/);
|
|
113
|
+
const tagValue = fc.oneof(fc.constant(''), fc.stringMatching(/^[A-Za-z0-9 :;\\]{0,16}$/));
|
|
114
|
+
const tagsArb = fc
|
|
115
|
+
.uniqueArray(fc.tuple(tagKey, tagValue), { selector: (e) => e[0], maxLength: 3 })
|
|
116
|
+
.map((entries) => Object.fromEntries(entries) as Record<string, string>);
|
|
117
|
+
const sourceArb = fc
|
|
118
|
+
.record({
|
|
119
|
+
name: fc.stringMatching(/^[A-Za-z0-9][A-Za-z0-9.\\-]{0,19}$/),
|
|
120
|
+
user: fc.option(fc.stringMatching(/^[A-Za-z0-9._~+\\-]{1,10}$/), { nil: undefined }),
|
|
121
|
+
host: fc.option(fc.stringMatching(/^[A-Za-z0-9.\\-]{1,20}$/), { nil: undefined }),
|
|
122
|
+
})
|
|
123
|
+
.map((s): IrcSource => {
|
|
124
|
+
const out: IrcSource = { name: s.name };
|
|
125
|
+
if (s.user !== undefined) out.user = s.user;
|
|
126
|
+
if (s.host !== undefined) out.host = s.host;
|
|
127
|
+
return out;
|
|
128
|
+
});
|
|
129
|
+
const paramsArb = fc
|
|
130
|
+
.tuple(fc.array(safeMiddle, { maxLength: 6 }), fc.option(freeTrailing, { nil: undefined }))
|
|
131
|
+
.map(([mids, last]) => (last === undefined ? mids : [...mids, last]));
|
|
132
|
+
|
|
133
|
+
const messageArb = fc
|
|
134
|
+
.record({
|
|
135
|
+
tags: tagsArb,
|
|
136
|
+
command: commandArb,
|
|
137
|
+
params: paramsArb,
|
|
138
|
+
source: fc.option(sourceArb, { nil: undefined }),
|
|
139
|
+
})
|
|
140
|
+
.map((m): IrcMessage => {
|
|
141
|
+
const out: IrcMessage = { tags: m.tags, command: m.command, params: m.params };
|
|
142
|
+
if (m.source !== undefined) out.source = m.source;
|
|
143
|
+
return out;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('parse(serialize(msg)) equals msg for all generated messages', () => {
|
|
147
|
+
fc.assert(
|
|
148
|
+
fc.property(messageArb, (msg) => {
|
|
149
|
+
expect(parse(serialize(msg))).toEqual(msg);
|
|
150
|
+
}),
|
|
151
|
+
{ numRuns: 500 },
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('never emits CR, LF or NUL in a serialized (non-frame) line', () => {
|
|
156
|
+
fc.assert(
|
|
157
|
+
fc.property(messageArb, (msg) => {
|
|
158
|
+
const line = serialize(msg);
|
|
159
|
+
expect(line).not.toMatch(/[\r\n\0]/);
|
|
160
|
+
}),
|
|
161
|
+
{ numRuns: 500 },
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
type ChanSnapshot,
|
|
4
|
+
type ChannelDelta,
|
|
5
|
+
type ChannelModes,
|
|
6
|
+
type ChannelTopic,
|
|
7
|
+
type MembershipDelta,
|
|
8
|
+
type Roster,
|
|
9
|
+
type RosterEntry,
|
|
10
|
+
applyChannelDelta,
|
|
11
|
+
createChannel,
|
|
12
|
+
emptyChannelDelta,
|
|
13
|
+
emptyModes,
|
|
14
|
+
prefixOf,
|
|
15
|
+
toSnapshot,
|
|
16
|
+
} from '../../src/state/channel';
|
|
17
|
+
|
|
18
|
+
describe('emptyModes', () => {
|
|
19
|
+
it('returns a modes object with every known channel mode disabled', () => {
|
|
20
|
+
const modes = emptyModes();
|
|
21
|
+
expect(modes).toEqual<ChannelModes>({
|
|
22
|
+
inviteOnly: false,
|
|
23
|
+
topicLock: false,
|
|
24
|
+
noExternal: false,
|
|
25
|
+
moderated: false,
|
|
26
|
+
secret: false,
|
|
27
|
+
private: false,
|
|
28
|
+
});
|
|
29
|
+
// optional fields are absent, not undefined
|
|
30
|
+
expect('key' in modes).toBe(false);
|
|
31
|
+
expect('limit' in modes).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('createChannel', () => {
|
|
36
|
+
it('creates an empty channel with default modes and no members', () => {
|
|
37
|
+
const chan = createChannel({ name: '#foo', nameLower: '#foo', createdAt: 5 });
|
|
38
|
+
expect(chan.name).toBe('#foo');
|
|
39
|
+
expect(chan.nameLower).toBe('#foo');
|
|
40
|
+
expect(chan.createdAt).toBe(5);
|
|
41
|
+
expect(chan.members.size).toBe(0);
|
|
42
|
+
expect(chan.banMasks.size).toBe(0);
|
|
43
|
+
expect(chan.pendingInvites.size).toBe(0);
|
|
44
|
+
expect(chan.modes).toEqual(emptyModes());
|
|
45
|
+
expect(chan.topic).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('honors an initial topic', () => {
|
|
49
|
+
const topic: ChannelTopic = { text: 'hi', setter: 'a!u@h', setAt: 9 };
|
|
50
|
+
const chan = createChannel({ name: '#x', nameLower: '#x', createdAt: 0, topic });
|
|
51
|
+
expect(chan.topic).toBe(topic);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('prefixOf', () => {
|
|
56
|
+
const entry = (over: Partial<RosterEntry>): RosterEntry => ({
|
|
57
|
+
conn: 'c1',
|
|
58
|
+
nick: 'n',
|
|
59
|
+
op: false,
|
|
60
|
+
voice: false,
|
|
61
|
+
...over,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns the empty string for a plain member', () => {
|
|
65
|
+
expect(prefixOf(entry({}))).toBe('');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns "@" for an op', () => {
|
|
69
|
+
expect(prefixOf(entry({ op: true }))).toBe('@');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns "+" for a voiced user', () => {
|
|
73
|
+
expect(prefixOf(entry({ voice: true }))).toBe('+');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns "@+" for an op who is also voiced', () => {
|
|
77
|
+
expect(prefixOf(entry({ op: true, voice: true }))).toBe('@+');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('toSnapshot', () => {
|
|
82
|
+
it('returns a read-only view of the channel state', () => {
|
|
83
|
+
const chan = createChannel({ name: '#foo', nameLower: '#foo', createdAt: 7 });
|
|
84
|
+
chan.members.set('c1', { conn: 'c1', nick: 'a', op: true, voice: false });
|
|
85
|
+
chan.banMasks.add('*!bad@*');
|
|
86
|
+
chan.pendingInvites.add('c2');
|
|
87
|
+
chan.modes.topicLock = true;
|
|
88
|
+
|
|
89
|
+
const snap = toSnapshot(chan);
|
|
90
|
+
expect(snap.name).toBe('#foo');
|
|
91
|
+
expect(snap.createdAt).toBe(7);
|
|
92
|
+
expect(snap.members.size).toBe(1);
|
|
93
|
+
expect(snap.banMasks.has('*!bad@*')).toBe(true);
|
|
94
|
+
expect(snap.pendingInvites.has('c2')).toBe(true);
|
|
95
|
+
expect(snap.modes.topicLock).toBe(true);
|
|
96
|
+
expect(snap.topic).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('produces a snapshot with topic when channel has one', () => {
|
|
100
|
+
const topic: ChannelTopic = { text: 't', setter: 's', setAt: 1 };
|
|
101
|
+
const chan = createChannel({ name: '#x', nameLower: '#x', createdAt: 0, topic });
|
|
102
|
+
const snap = toSnapshot(chan);
|
|
103
|
+
expect(snap.topic).toEqual(topic);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('emptyChannelDelta', () => {
|
|
108
|
+
it('returns a delta with no memberships, no topic, and no mode changes', () => {
|
|
109
|
+
const delta = emptyChannelDelta();
|
|
110
|
+
expect(delta.memberships).toEqual([]);
|
|
111
|
+
expect(delta.topic).toBeUndefined();
|
|
112
|
+
expect(delta.modeChanges).toEqual([]);
|
|
113
|
+
expect(delta.banMaskChanges).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('applyChannelDelta', () => {
|
|
118
|
+
it('adds a new member via membership delta', () => {
|
|
119
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
120
|
+
const delta: ChannelDelta = {
|
|
121
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'a', op: true, voice: false }],
|
|
122
|
+
};
|
|
123
|
+
const next = applyChannelDelta(chan, delta);
|
|
124
|
+
expect(next.members.size).toBe(1);
|
|
125
|
+
expect(next.members.get('c1')?.op).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('updates an existing member via membership delta', () => {
|
|
129
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
130
|
+
chan.members.set('c1', { conn: 'c1', nick: 'a', op: false, voice: false });
|
|
131
|
+
const delta: ChannelDelta = {
|
|
132
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'a', op: true, voice: true }],
|
|
133
|
+
};
|
|
134
|
+
const next = applyChannelDelta(chan, delta);
|
|
135
|
+
expect(next.members.get('c1')?.op).toBe(true);
|
|
136
|
+
expect(next.members.get('c1')?.voice).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('removes a member via membership delta', () => {
|
|
140
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
141
|
+
chan.members.set('c1', { conn: 'c1', nick: 'a', op: false, voice: false });
|
|
142
|
+
const delta: ChannelDelta = {
|
|
143
|
+
memberships: [{ type: 'remove', conn: 'c1' }],
|
|
144
|
+
};
|
|
145
|
+
const next = applyChannelDelta(chan, delta);
|
|
146
|
+
expect(next.members.has('c1')).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('ignores removal of a non-existent member', () => {
|
|
150
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
151
|
+
const delta: ChannelDelta = {
|
|
152
|
+
memberships: [{ type: 'remove', conn: 'nope' }],
|
|
153
|
+
};
|
|
154
|
+
const next = applyChannelDelta(chan, delta);
|
|
155
|
+
expect(next.members.size).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('skips an add delta that is missing a nick', () => {
|
|
159
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
160
|
+
const delta: ChannelDelta = {
|
|
161
|
+
memberships: [{ type: 'add', conn: 'c1' }],
|
|
162
|
+
};
|
|
163
|
+
const next = applyChannelDelta(chan, delta);
|
|
164
|
+
expect(next.members.has('c1')).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('defaults op and voice to false when omitted on add', () => {
|
|
168
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
169
|
+
const delta: ChannelDelta = {
|
|
170
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'a' }],
|
|
171
|
+
};
|
|
172
|
+
const next = applyChannelDelta(chan, delta);
|
|
173
|
+
expect(next.members.get('c1')?.op).toBe(false);
|
|
174
|
+
expect(next.members.get('c1')?.voice).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('sets a fresh topic', () => {
|
|
178
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
179
|
+
const topic: ChannelTopic = { text: 'hello', setter: 'a', setAt: 10 };
|
|
180
|
+
const delta: ChannelDelta = { topic };
|
|
181
|
+
const next = applyChannelDelta(chan, delta);
|
|
182
|
+
expect(next.topic).toEqual(topic);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('clears the topic when delta.topic is null', () => {
|
|
186
|
+
const chan = createChannel({
|
|
187
|
+
name: '#f',
|
|
188
|
+
nameLower: '#f',
|
|
189
|
+
createdAt: 0,
|
|
190
|
+
topic: { text: 'old', setter: 'a', setAt: 1 },
|
|
191
|
+
});
|
|
192
|
+
const delta: ChannelDelta = { topic: null };
|
|
193
|
+
const next = applyChannelDelta(chan, delta);
|
|
194
|
+
expect(next.topic).toBeUndefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('applies a boolean mode change', () => {
|
|
198
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
199
|
+
const delta: ChannelDelta = {
|
|
200
|
+
modeChanges: [{ mode: 'topicLock', set: true }],
|
|
201
|
+
};
|
|
202
|
+
const next = applyChannelDelta(chan, delta);
|
|
203
|
+
expect(next.modes.topicLock).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('applies a key mode change', () => {
|
|
207
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
208
|
+
const delta: ChannelDelta = {
|
|
209
|
+
modeChanges: [{ mode: 'key', set: true, arg: 'secret' }],
|
|
210
|
+
};
|
|
211
|
+
const next = applyChannelDelta(chan, delta);
|
|
212
|
+
expect(next.modes.key).toBe('secret');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('clears the key when set=false', () => {
|
|
216
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
217
|
+
chan.modes.key = 'secret';
|
|
218
|
+
const delta: ChannelDelta = {
|
|
219
|
+
modeChanges: [{ mode: 'key', set: false }],
|
|
220
|
+
};
|
|
221
|
+
const next = applyChannelDelta(chan, delta);
|
|
222
|
+
expect(next.modes.key).toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('applies a limit mode change', () => {
|
|
226
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
227
|
+
const delta: ChannelDelta = {
|
|
228
|
+
modeChanges: [{ mode: 'limit', set: true, arg: 50 }],
|
|
229
|
+
};
|
|
230
|
+
const next = applyChannelDelta(chan, delta);
|
|
231
|
+
expect(next.modes.limit).toBe(50);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('clears the limit when set=false', () => {
|
|
235
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
236
|
+
chan.modes.limit = 10;
|
|
237
|
+
const delta: ChannelDelta = {
|
|
238
|
+
modeChanges: [{ mode: 'limit', set: false }],
|
|
239
|
+
};
|
|
240
|
+
const next = applyChannelDelta(chan, delta);
|
|
241
|
+
expect(next.modes.limit).toBeUndefined();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('ignores a key set without a string arg', () => {
|
|
245
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
246
|
+
const delta: ChannelDelta = {
|
|
247
|
+
modeChanges: [{ mode: 'key', set: true }],
|
|
248
|
+
};
|
|
249
|
+
const next = applyChannelDelta(chan, delta);
|
|
250
|
+
expect(next.modes.key).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('ignores a limit set without a numeric arg', () => {
|
|
254
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
255
|
+
const delta: ChannelDelta = {
|
|
256
|
+
modeChanges: [{ mode: 'limit', set: true }],
|
|
257
|
+
};
|
|
258
|
+
const next = applyChannelDelta(chan, delta);
|
|
259
|
+
expect(next.modes.limit).toBeUndefined();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('toggles every boolean mode (inviteOnly, noExternal, moderated, secret, private)', () => {
|
|
263
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
264
|
+
const delta: ChannelDelta = {
|
|
265
|
+
modeChanges: [
|
|
266
|
+
{ mode: 'inviteOnly', set: true },
|
|
267
|
+
{ mode: 'noExternal', set: true },
|
|
268
|
+
{ mode: 'moderated', set: true },
|
|
269
|
+
{ mode: 'secret', set: true },
|
|
270
|
+
{ mode: 'private', set: true },
|
|
271
|
+
],
|
|
272
|
+
};
|
|
273
|
+
const next = applyChannelDelta(chan, delta);
|
|
274
|
+
expect(next.modes.inviteOnly).toBe(true);
|
|
275
|
+
expect(next.modes.noExternal).toBe(true);
|
|
276
|
+
expect(next.modes.moderated).toBe(true);
|
|
277
|
+
expect(next.modes.secret).toBe(true);
|
|
278
|
+
expect(next.modes.private).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('applies ban mask changes (add and remove)', () => {
|
|
282
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
283
|
+
const delta: ChannelDelta = {
|
|
284
|
+
banMaskChanges: [
|
|
285
|
+
{ type: 'add', mask: '*!bad@*' },
|
|
286
|
+
{ type: 'add', mask: '*!worse@*' },
|
|
287
|
+
{ type: 'remove', mask: '*!bad@*' },
|
|
288
|
+
],
|
|
289
|
+
};
|
|
290
|
+
const next = applyChannelDelta(chan, delta);
|
|
291
|
+
expect(next.banMasks.has('*!bad@*')).toBe(false);
|
|
292
|
+
expect(next.banMasks.has('*!worse@*')).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('applies all delta fields in one call', () => {
|
|
296
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
297
|
+
chan.members.set('c1', { conn: 'c1', nick: 'a', op: false, voice: false });
|
|
298
|
+
const delta: ChannelDelta = {
|
|
299
|
+
memberships: [
|
|
300
|
+
{ type: 'add', conn: 'c2', nick: 'b', op: false, voice: true },
|
|
301
|
+
{ type: 'remove', conn: 'c1' },
|
|
302
|
+
],
|
|
303
|
+
topic: { text: 't', setter: 's', setAt: 1 },
|
|
304
|
+
modeChanges: [{ mode: 'secret', set: true }],
|
|
305
|
+
banMaskChanges: [{ type: 'add', mask: '*!x@*' }],
|
|
306
|
+
};
|
|
307
|
+
const next = applyChannelDelta(chan, delta);
|
|
308
|
+
expect(next.members.size).toBe(1);
|
|
309
|
+
expect(next.members.get('c2')?.voice).toBe(true);
|
|
310
|
+
expect(next.topic?.text).toBe('t');
|
|
311
|
+
expect(next.modes.secret).toBe(true);
|
|
312
|
+
expect(next.banMasks.has('*!x@*')).toBe(true);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('returns the channel unchanged for an empty delta', () => {
|
|
316
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
317
|
+
const next = applyChannelDelta(chan, emptyChannelDelta());
|
|
318
|
+
expect(next.members.size).toBe(0);
|
|
319
|
+
expect(next.modes).toEqual(emptyModes());
|
|
320
|
+
expect(next.topic).toBeUndefined();
|
|
321
|
+
expect(next.banMasks.size).toBe(0);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('returns a value equal-but-distinct reference for empty delta (no shared mutable aliasing)', () => {
|
|
325
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
326
|
+
const next = applyChannelDelta(chan, emptyChannelDelta());
|
|
327
|
+
// The function should be safe to call without mutating the source.
|
|
328
|
+
expect(next.name).toBe(chan.name);
|
|
329
|
+
expect(next).not.toBe(chan);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe('Roster type', () => {
|
|
334
|
+
it('exposes a read-only map alias', () => {
|
|
335
|
+
const chan = createChannel({ name: '#f', nameLower: '#f', createdAt: 0 });
|
|
336
|
+
chan.members.set('c1', { conn: 'c1', nick: 'a', op: true, voice: false });
|
|
337
|
+
const snap: ChanSnapshot = toSnapshot(chan);
|
|
338
|
+
const roster: Roster = snap.members;
|
|
339
|
+
expect(roster.size).toBe(1);
|
|
340
|
+
expect(roster.get('c1')?.nick).toBe('a');
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe('MembershipDelta', () => {
|
|
345
|
+
it('has add and remove literal types', () => {
|
|
346
|
+
const add: MembershipDelta = { type: 'add', conn: 'c1', nick: 'a', op: false, voice: false };
|
|
347
|
+
const remove: MembershipDelta = { type: 'remove', conn: 'c1' };
|
|
348
|
+
expect(add.type).toBe('add');
|
|
349
|
+
expect(remove.type).toBe('remove');
|
|
350
|
+
});
|
|
351
|
+
});
|