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,182 @@
|
|
|
1
|
+
import fc from 'fast-check';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import type { RawLine } from '../src/effects';
|
|
4
|
+
import { applyServerTime, filterClientTags } from '../src/protocol/outbound';
|
|
5
|
+
import { parse, unescapeTagValue } from '../src/protocol/parser';
|
|
6
|
+
import { escapeTagValue, serialize } from '../src/protocol/serializer';
|
|
7
|
+
|
|
8
|
+
const L = (text: string): RawLine => ({ text });
|
|
9
|
+
const NOW = 1_705_329_296_789; // 2024-01-15T14:34:56.789Z
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// filterClientTags — IRCv3 message-tags client-tag gating
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
describe('filterClientTags — cap gating', () => {
|
|
16
|
+
it('returns the line unchanged for a message-tags client', () => {
|
|
17
|
+
const line = L('@+typing=1 :alice PRIVMSG #foo :hi');
|
|
18
|
+
expect(filterClientTags(line, new Set<string>(['message-tags']))).toBe(line);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('strips a single valued client tag (+) for a non-message-tags client', () => {
|
|
22
|
+
const line = L('@+typing=1 :alice PRIVMSG #foo :hi');
|
|
23
|
+
const out = filterClientTags(line, new Set<string>());
|
|
24
|
+
expect(out.text).toBe(':alice PRIVMSG #foo :hi');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('drops the entire tag section when only client tags were present', () => {
|
|
28
|
+
const line = L('@+away :alice PRIVMSG #foo :hi');
|
|
29
|
+
const out = filterClientTags(line, new Set<string>());
|
|
30
|
+
expect(out.text).toBe(':alice PRIVMSG #foo :hi');
|
|
31
|
+
expect(out.text.startsWith('@')).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('keeps server tags but removes client tags for a legacy client', () => {
|
|
35
|
+
const line = L('@account=alice;+typing=1 :alice PRIVMSG #foo :hi');
|
|
36
|
+
const out = filterClientTags(line, new Set<string>());
|
|
37
|
+
expect(out.text).toBe('@account=alice :alice PRIVMSG #foo :hi');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('keeps the equals-less form of a server tag and drops a valueless client tag', () => {
|
|
41
|
+
const line = L('@+away;msgid=abc :alice PRIVMSG #foo :hi');
|
|
42
|
+
const out = filterClientTags(line, new Set<string>());
|
|
43
|
+
expect(out.text).toBe('@msgid=abc :alice PRIVMSG #foo :hi');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns the same object reference when there are no client tags to strip', () => {
|
|
47
|
+
const line = L('@account=alice :alice PRIVMSG #foo :hi');
|
|
48
|
+
expect(filterClientTags(line, new Set<string>())).toBe(line);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns the line unchanged when it has no tag section', () => {
|
|
52
|
+
const line = L(':alice PRIVMSG #foo :hi');
|
|
53
|
+
expect(filterClientTags(line, new Set<string>())).toBe(line);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns a malformed @-prefixed line with no body unchanged', () => {
|
|
57
|
+
const line = L('@+typing');
|
|
58
|
+
expect(filterClientTags(line, new Set<string>())).toBe(line);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('does not mutate the input RawLine', () => {
|
|
62
|
+
const line = L('@+typing=1 :alice PRIVMSG #foo :hi');
|
|
63
|
+
const snapshot = line.text;
|
|
64
|
+
filterClientTags(line, new Set<string>());
|
|
65
|
+
expect(line.text).toBe(snapshot);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('preserves server and client tags together for a message-tags client', () => {
|
|
69
|
+
const line = L('@account=alice;+typing=1 :alice PRIVMSG #foo :hi');
|
|
70
|
+
const out = filterClientTags(line, new Set<string>(['message-tags']));
|
|
71
|
+
expect(out.text).toBe('@account=alice;+typing=1 :alice PRIVMSG #foo :hi');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('preserves an escaped value within a kept server tag verbatim', () => {
|
|
75
|
+
const line = L('@label=a\\sb;+typing=1 :alice PRIVMSG #foo :hi');
|
|
76
|
+
const out = filterClientTags(line, new Set<string>());
|
|
77
|
+
expect(out.text).toBe('@label=a\\sb :alice PRIVMSG #foo :hi');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ============================================================================
|
|
82
|
+
// filterClientTags + applyServerTime composition (dispatch-layer ordering)
|
|
83
|
+
// ============================================================================
|
|
84
|
+
|
|
85
|
+
describe('filterClientTags + applyServerTime composition', () => {
|
|
86
|
+
const base = L('@+typing=1 :alice PRIVMSG #foo :hi');
|
|
87
|
+
|
|
88
|
+
it('keeps @time and client tags for a server-time + message-tags recipient', () => {
|
|
89
|
+
const caps = new Set<string>(['server-time', 'message-tags']);
|
|
90
|
+
const out = filterClientTags(applyServerTime(base, caps, NOW), caps);
|
|
91
|
+
expect(out.text).toBe('@time=2024-01-15T14:34:56.789Z;+typing=1 :alice PRIVMSG #foo :hi');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('keeps @time but strips client tags for a server-time-only recipient', () => {
|
|
95
|
+
const caps = new Set<string>(['server-time']);
|
|
96
|
+
const out = filterClientTags(applyServerTime(base, caps, NOW), caps);
|
|
97
|
+
expect(out.text).toBe('@time=2024-01-15T14:34:56.789Z :alice PRIVMSG #foo :hi');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('drops the client tag and tag section for a cap-less recipient (no server-time added)', () => {
|
|
101
|
+
const caps = new Set<string>();
|
|
102
|
+
const out = filterClientTags(applyServerTime(base, caps, NOW), caps);
|
|
103
|
+
expect(out.text).toBe(':alice PRIVMSG #foo :hi');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ============================================================================
|
|
108
|
+
// escapeTagValue / unescapeTagValue codec round-trip
|
|
109
|
+
// ============================================================================
|
|
110
|
+
|
|
111
|
+
describe('escapeTagValue — spec escapes', () => {
|
|
112
|
+
it('escapes semicolon as \\:', () => {
|
|
113
|
+
expect(escapeTagValue(';')).toBe('\\:');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('escapes space as \\s', () => {
|
|
117
|
+
expect(escapeTagValue(' ')).toBe('\\s');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('escapes backslash as \\\\', () => {
|
|
121
|
+
expect(escapeTagValue('\\')).toBe('\\\\');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('escapes CR as \\r', () => {
|
|
125
|
+
expect(escapeTagValue('\r')).toBe('\\r');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('escapes LF as \\n', () => {
|
|
129
|
+
expect(escapeTagValue('\n')).toBe('\\n');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('leaves ordinary characters untouched', () => {
|
|
133
|
+
expect(escapeTagValue('abc123_.-+/')).toBe('abc123_.-+/');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('unescapeTagValue — spec unescapes', () => {
|
|
138
|
+
it('unescapes \\: to ;', () => {
|
|
139
|
+
expect(unescapeTagValue('\\:')).toBe(';');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('unescapes \\s to space', () => {
|
|
143
|
+
expect(unescapeTagValue('\\s')).toBe(' ');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('unescapes \\\\ to \\', () => {
|
|
147
|
+
expect(unescapeTagValue('\\\\')).toBe('\\');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('unescapes \\r to CR', () => {
|
|
151
|
+
expect(unescapeTagValue('\\r')).toBe('\r');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('unescapes \\n to LF', () => {
|
|
155
|
+
expect(unescapeTagValue('\\n')).toBe('\n');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('escapeTagValue / unescapeTagValue round-trip', () => {
|
|
160
|
+
it('round-trips a string containing every special character', () => {
|
|
161
|
+
const original = 'a b;c\\d\re\nf';
|
|
162
|
+
expect(unescapeTagValue(escapeTagValue(original))).toBe(original);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('round-trips arbitrary strings over the special-character alphabet', () => {
|
|
166
|
+
const arb = fc.stringOf(fc.constantFrom('\\', ';', ' ', 's', 'r', 'n', ':', 'a', 'b', '1'), {
|
|
167
|
+
maxLength: 32,
|
|
168
|
+
});
|
|
169
|
+
fc.assert(
|
|
170
|
+
fc.property(arb, (s) => {
|
|
171
|
+
expect(unescapeTagValue(escapeTagValue(s))).toBe(s);
|
|
172
|
+
}),
|
|
173
|
+
{ numRuns: 500 },
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('serializer escape then parser unescape yields the original value', () => {
|
|
178
|
+
const value = 'hi ; there \\ done';
|
|
179
|
+
const wire = serialize({ tags: { k: value }, command: 'PING', params: [] });
|
|
180
|
+
expect(parse(wire).tags.k).toBe(value);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { NumericCode } from '../src/protocol/numerics';
|
|
3
|
+
import { Numerics, formatNumeric, isNumericCommand, numericToName } from '../src/protocol/numerics';
|
|
4
|
+
|
|
5
|
+
describe('Numerics registry', () => {
|
|
6
|
+
it('maps RPL_WELCOME to 1', () => {
|
|
7
|
+
expect(Numerics.RPL_WELCOME).toBe(1);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('maps RPL_ISUPPORT to 5', () => {
|
|
11
|
+
expect(Numerics.RPL_ISUPPORT).toBe(5);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('maps RPL_ENDOFMOTD to 376', () => {
|
|
15
|
+
expect(Numerics.RPL_ENDOFMOTD).toBe(376);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('maps ERR_NOSUCHNICK to 401', () => {
|
|
19
|
+
expect(Numerics.ERR_NOSUCHNICK).toBe(401);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('maps ERR_NEEDMOREPARAMS to 461', () => {
|
|
23
|
+
expect(Numerics.ERR_NEEDMOREPARAMS).toBe(461);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('maps ERR_NICKNAMEINUSE to 433', () => {
|
|
27
|
+
expect(Numerics.ERR_NICKNAMEINUSE).toBe(433);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('maps ERR_CHANOPRIVSNEEDED to 482', () => {
|
|
31
|
+
expect(Numerics.ERR_CHANOPRIVSNEEDED).toBe(482);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('keeps every code within the 3-digit range [1,999]', () => {
|
|
35
|
+
for (const code of Object.values(Numerics)) {
|
|
36
|
+
expect(code).toBeGreaterThanOrEqual(1);
|
|
37
|
+
expect(code).toBeLessThanOrEqual(999);
|
|
38
|
+
expect(Number.isInteger(code)).toBe(true);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('uses unique codes across all names (no aliases collide)', () => {
|
|
43
|
+
const codes = Object.values(Numerics);
|
|
44
|
+
expect(new Set(codes).size).toBe(codes.length);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('uses SCREAMING_SNAKE_CASE names only', () => {
|
|
48
|
+
for (const name of Object.keys(Numerics)) {
|
|
49
|
+
expect(name).toMatch(/^[A-Z][A-Z0-9_]*$/);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps NumericCode assignable to keys of Numerics', () => {
|
|
54
|
+
const name: NumericCode = 'ERR_NICKNAMEINUSE';
|
|
55
|
+
expect(Numerics[name]).toBe(433);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('numericToName', () => {
|
|
60
|
+
it('reverses ERR_NICKNAMEINUSE', () => {
|
|
61
|
+
expect(numericToName.get(433)).toBe('ERR_NICKNAMEINUSE');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('reverses RPL_WELCOME', () => {
|
|
65
|
+
expect(numericToName.get(1)).toBe('RPL_WELCOME');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns undefined for an unregistered code', () => {
|
|
69
|
+
expect(numericToName.get(600)).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('isNumericCommand', () => {
|
|
74
|
+
it.each(['001', '005', '433', '999'])('treats %s as a numeric command', (code) => {
|
|
75
|
+
expect(isNumericCommand(code)).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it.each(['PRIVMSG', 'privmsg', 'nick', '', '1', '01', '0001', '1234', 'PING '])(
|
|
79
|
+
'treats %j as a non-numeric command',
|
|
80
|
+
(code) => {
|
|
81
|
+
expect(isNumericCommand(code)).toBe(false);
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('formatNumeric', () => {
|
|
87
|
+
it('zero-pads a single digit to three places', () => {
|
|
88
|
+
expect(formatNumeric('RPL_WELCOME')).toBe('001');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('formats a three-digit code unchanged', () => {
|
|
92
|
+
expect(formatNumeric('ERR_NICKNAMEINUSE')).toBe('433');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('zero-pads RPL_ISUPPORT', () => {
|
|
96
|
+
expect(formatNumeric('RPL_ISUPPORT')).toBe('005');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import fc from 'fast-check';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import type { RawLine } from '../src/effects';
|
|
4
|
+
import {
|
|
5
|
+
MAX_LINE_BYTES,
|
|
6
|
+
applyServerTime,
|
|
7
|
+
enforceLineLimit,
|
|
8
|
+
formatServerTime,
|
|
9
|
+
} from '../src/protocol/outbound';
|
|
10
|
+
import { parse } from '../src/protocol/parser';
|
|
11
|
+
import { serialize } from '../src/protocol/serializer';
|
|
12
|
+
|
|
13
|
+
const L = (text: string): RawLine => ({ text });
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// formatServerTime — ISO-8601 with milliseconds
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
describe('formatServerTime', () => {
|
|
20
|
+
it('formats epoch ms as ISO-8601 with millisecond precision and a Z suffix', () => {
|
|
21
|
+
// 2024-01-15T14:34:56.789Z
|
|
22
|
+
expect(formatServerTime(1_705_329_296_789)).toBe('2024-01-15T14:34:56.789Z');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('zero-pads month, day, hour, minute, second, and millisecond fields', () => {
|
|
26
|
+
// 2023-01-01T00:00:00.000Z
|
|
27
|
+
expect(formatServerTime(1_672_531_200_000)).toBe('2023-01-01T00:00:00.000Z');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('always emits exactly three millisecond digits', () => {
|
|
31
|
+
expect(formatServerTime(1_672_531_200_000)).toMatch(/\.\d{3}Z$/u);
|
|
32
|
+
expect(formatServerTime(1_672_531_200_500)).toMatch(/\.\d{3}Z$/u);
|
|
33
|
+
expect(formatServerTime(1_672_531_200_999)).toMatch(/\.\d{3}Z$/u);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rounds/truncates consistently with Date#toISOString', () => {
|
|
37
|
+
const now = 1_705_329_296_789;
|
|
38
|
+
expect(formatServerTime(now)).toBe(new Date(now).toISOString());
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// applyServerTime — per-recipient @time=... prepending
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
describe('applyServerTime', () => {
|
|
47
|
+
const PRIVMSG_LINE: RawLine = L(':alice!alice@example.com PRIVMSG #foo :hello');
|
|
48
|
+
|
|
49
|
+
it('prepends @time=<iso> when the recipient negotiated server-time', () => {
|
|
50
|
+
const caps = new Set<string>(['server-time']);
|
|
51
|
+
const out = applyServerTime(PRIVMSG_LINE, caps, 1_705_329_296_789);
|
|
52
|
+
expect(out.text).toBe(
|
|
53
|
+
'@time=2024-01-15T14:34:56.789Z :alice!alice@example.com PRIVMSG #foo :hello',
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns the line unchanged when the recipient lacks the server-time cap', () => {
|
|
58
|
+
const caps = new Set<string>(['multi-prefix', 'echo-message']);
|
|
59
|
+
const out = applyServerTime(PRIVMSG_LINE, caps, 1_705_329_296_789);
|
|
60
|
+
expect(out).toBe(PRIVMSG_LINE);
|
|
61
|
+
expect(out.text).toBe(':alice!alice@example.com PRIVMSG #foo :hello');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns the line unchanged when the recipient has no caps at all', () => {
|
|
65
|
+
const caps = new Set<string>();
|
|
66
|
+
const out = applyServerTime(PRIVMSG_LINE, caps, 1_705_329_296_789);
|
|
67
|
+
expect(out.text).toBe(':alice!alice@example.com PRIVMSG #foo :hello');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('prepends to numeric replies too (server-time applies to all outbound messages)', () => {
|
|
71
|
+
const caps = new Set<string>(['server-time']);
|
|
72
|
+
const numeric: RawLine = L(':irc.example.com 001 alice :Welcome to the network');
|
|
73
|
+
const out = applyServerTime(numeric, caps, 1_672_531_200_000);
|
|
74
|
+
expect(out.text).toBe(
|
|
75
|
+
'@time=2023-01-01T00:00:00.000Z :irc.example.com 001 alice :Welcome to the network',
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('does not mutate the input RawLine', () => {
|
|
80
|
+
const caps = new Set<string>(['server-time']);
|
|
81
|
+
const original: RawLine = { text: ':alice PRIVMSG #foo :hi' };
|
|
82
|
+
const snapshot = original.text;
|
|
83
|
+
applyServerTime(original, caps, 1_705_329_296_789);
|
|
84
|
+
expect(original.text).toBe(snapshot);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('merges into an existing tag section instead of emitting two @ prefixes', () => {
|
|
88
|
+
// A line that already carries an account tag (e.g. from a message-tags
|
|
89
|
+
// enabled reducer) must get its `time` tag merged in after the `@`.
|
|
90
|
+
const caps = new Set<string>(['server-time']);
|
|
91
|
+
const tagged: RawLine = L('@account=alice :alice PRIVMSG #foo :hi');
|
|
92
|
+
const out = applyServerTime(tagged, caps, 1_705_329_296_789);
|
|
93
|
+
expect(out.text).toBe('@time=2024-01-15T14:34:56.789Z;account=alice :alice PRIVMSG #foo :hi');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ============================================================================
|
|
98
|
+
// enforceLineLimit — 512-byte wire limit including tags
|
|
99
|
+
// ============================================================================
|
|
100
|
+
|
|
101
|
+
describe('enforceLineLimit', () => {
|
|
102
|
+
// MAX_LINE_BYTES (512) is the wire budget INCLUDING the trailing CR-LF the
|
|
103
|
+
// wire layer appends. enforceLineLimit operates on RawLine.text (no CRLF),
|
|
104
|
+
// so the in-memory text budget is MAX_LINE_BYTES - 2 = 510 bytes.
|
|
105
|
+
const TEXT_BUDGET = MAX_LINE_BYTES - 2;
|
|
106
|
+
|
|
107
|
+
it('passes a short line through unchanged for a legacy client', () => {
|
|
108
|
+
const line: RawLine = L(':alice PRIVMSG #foo :hi');
|
|
109
|
+
const out = enforceLineLimit(line, new Set<string>());
|
|
110
|
+
expect(out).toBe(line);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('passes a short line through unchanged for a message-tags client', () => {
|
|
114
|
+
const line: RawLine = L('@time=2024-01-15T12:34:56.789Z :alice PRIVMSG #foo :hi');
|
|
115
|
+
const out = enforceLineLimit(line, new Set<string>(['message-tags']));
|
|
116
|
+
expect(out).toBe(line);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('truncates a body-only line that exceeds the wire budget for a legacy client', () => {
|
|
120
|
+
const prefix = ':alice PRIVMSG #foo :';
|
|
121
|
+
const padding = 'x'.repeat(TEXT_BUDGET - prefix.length);
|
|
122
|
+
const exact: RawLine = L(`${prefix}${padding}`);
|
|
123
|
+
expect(exact.text.length).toBe(TEXT_BUDGET);
|
|
124
|
+
expect(enforceLineLimit(exact, new Set<string>()).text.length).toBe(TEXT_BUDGET);
|
|
125
|
+
|
|
126
|
+
const tooLong: RawLine = L(`${prefix}${padding}YYYYY`);
|
|
127
|
+
const out = enforceLineLimit(tooLong, new Set<string>());
|
|
128
|
+
expect(out.text.length).toBe(TEXT_BUDGET);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('counts the tag section toward the wire budget for message-tags clients', () => {
|
|
132
|
+
const tagPrefix = '@time=2024-01-15T12:34:56.789Z '; // 31 bytes incl trailing space
|
|
133
|
+
const body = `:alice PRIVMSG #foo :${'x'.repeat(490)}`; // prefix(21) + 490 = 511
|
|
134
|
+
const line: RawLine = L(`${tagPrefix}${body}`);
|
|
135
|
+
expect(line.text.length).toBeGreaterThan(TEXT_BUDGET);
|
|
136
|
+
|
|
137
|
+
const out = enforceLineLimit(line, new Set<string>(['message-tags']));
|
|
138
|
+
expect(out.text.length).toBe(TEXT_BUDGET);
|
|
139
|
+
expect(out.text.startsWith(tagPrefix)).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('preserves the tag section verbatim while truncating only the trailing body', () => {
|
|
143
|
+
const tagPrefix = '@time=2024-01-15T12:34:56.789Z ';
|
|
144
|
+
const body = `:alice PRIVMSG #foo :${'x'.repeat(500)}`;
|
|
145
|
+
const line: RawLine = L(`${tagPrefix}${body}`);
|
|
146
|
+
const out = enforceLineLimit(line, new Set<string>(['message-tags']));
|
|
147
|
+
expect(out.text.length).toBe(TEXT_BUDGET);
|
|
148
|
+
expect(out.text.startsWith(tagPrefix)).toBe(true);
|
|
149
|
+
expect(out.text.endsWith('xxx')).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does not strip tags from a message-tags client even when the line is very long', () => {
|
|
153
|
+
const tagPrefix = '@time=2024-01-15T12:34:56.789Z ';
|
|
154
|
+
const body = `:alice PRIVMSG #foo :${'x'.repeat(1000)}`;
|
|
155
|
+
const line: RawLine = L(`${tagPrefix}${body}`);
|
|
156
|
+
const out = enforceLineLimit(line, new Set<string>(['message-tags']));
|
|
157
|
+
expect(out.text.startsWith(tagPrefix)).toBe(true);
|
|
158
|
+
expect(out.text.length).toBe(TEXT_BUDGET);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('handles a legacy client line that already has tags (defensive — never delivered)', () => {
|
|
162
|
+
// The runtime should never deliver a tagged line to a legacy client, but
|
|
163
|
+
// enforceLineLimit must still keep its output within the budget.
|
|
164
|
+
const line: RawLine = L(`@a=b ${'x'.repeat(600)}`);
|
|
165
|
+
const out = enforceLineLimit(line, new Set<string>());
|
|
166
|
+
expect(out.text.length).toBe(TEXT_BUDGET);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('truncates from the right when a message-tags client sends a body with no @ prefix', () => {
|
|
170
|
+
// Defensive: caps says message-tags but the line never got decorated.
|
|
171
|
+
// We fall through to the legacy truncation path.
|
|
172
|
+
const line: RawLine = L(`:alice PRIVMSG #foo :${'x'.repeat(600)}`);
|
|
173
|
+
const out = enforceLineLimit(line, new Set<string>(['message-tags']));
|
|
174
|
+
expect(out.text.length).toBe(TEXT_BUDGET);
|
|
175
|
+
expect(out.text.startsWith(':alice')).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('truncates from the right when a message-tags line has no space after the tag section', () => {
|
|
179
|
+
// Malformed wire input; defensive truncation.
|
|
180
|
+
const line: RawLine = L(`@a=b${'x'.repeat(600)}`);
|
|
181
|
+
const out = enforceLineLimit(line, new Set<string>(['message-tags']));
|
|
182
|
+
expect(out.text.length).toBe(TEXT_BUDGET);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('truncates the tag section itself when it alone exceeds the budget', () => {
|
|
186
|
+
// Pathological: the tag section is so large that no body would fit. The
|
|
187
|
+
// contract (output ≤ budget bytes) must still hold.
|
|
188
|
+
const giantTag = `@${'a'.repeat(700)}`;
|
|
189
|
+
const line: RawLine = L(`${giantTag} :alice PRIVMSG #foo :hi`);
|
|
190
|
+
const out = enforceLineLimit(line, new Set<string>(['message-tags']));
|
|
191
|
+
expect(out.text.length).toBe(TEXT_BUDGET);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ============================================================================
|
|
196
|
+
// Round-trip through serialize -> applyServerTime -> parse
|
|
197
|
+
// ============================================================================
|
|
198
|
+
|
|
199
|
+
describe('server-time + tag escaping round-trip (property test)', () => {
|
|
200
|
+
const tagValue = fc.oneof(fc.constant(''), fc.stringMatching(/^[A-Za-z0-9 :;\\]{0,32}$/));
|
|
201
|
+
const tagKey = fc.stringMatching(/^[a-z][a-z0-9/+\\-]{0,14}$/);
|
|
202
|
+
|
|
203
|
+
it('parse(serialize(msg)) preserves the time tag across all generated values', () => {
|
|
204
|
+
fc.assert(
|
|
205
|
+
fc.property(fc.tuple(tagKey, tagValue), ([key, value]) => {
|
|
206
|
+
const msg = {
|
|
207
|
+
tags: value === '' ? { [key]: '' } : { [key]: value },
|
|
208
|
+
command: 'PRIVMSG' as const,
|
|
209
|
+
params: ['#foo', 'hello'],
|
|
210
|
+
};
|
|
211
|
+
const wire = serialize(msg);
|
|
212
|
+
const reParsed = parse(wire);
|
|
213
|
+
expect(reParsed.tags).toEqual(msg.tags);
|
|
214
|
+
}),
|
|
215
|
+
{ numRuns: 200 },
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('applyServerTime produces a line parseable back into the time tag', () => {
|
|
220
|
+
const base: RawLine = L(':alice PRIVMSG #foo :hi');
|
|
221
|
+
fc.assert(
|
|
222
|
+
fc.property(fc.integer({ min: 0, max: 4_000_000_000_000 }), (now) => {
|
|
223
|
+
const caps = new Set<string>(['server-time']);
|
|
224
|
+
const decorated = applyServerTime(base, caps, now);
|
|
225
|
+
// The decorated line must be parseable and contain the time tag we put there.
|
|
226
|
+
const parsed = parse(decorated.text);
|
|
227
|
+
expect(parsed.tags.time).toBe(formatServerTime(now));
|
|
228
|
+
}),
|
|
229
|
+
{ numRuns: 100 },
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { IrcMessage } from '../src/protocol/messages';
|
|
3
|
+
import { IrcParseError, parse } from '../src/protocol/parser';
|
|
4
|
+
|
|
5
|
+
describe('parse — golden vectors', () => {
|
|
6
|
+
it('parses a simple command with a channel and trailing text', () => {
|
|
7
|
+
expect(parse('PRIVMSG #foo :hello world')).toEqual<IrcMessage>({
|
|
8
|
+
tags: {},
|
|
9
|
+
command: 'PRIVMSG',
|
|
10
|
+
params: ['#foo', 'hello world'],
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('parses a nick!user@host source', () => {
|
|
15
|
+
expect(parse(':nick!user@host PRIVMSG #foo :hi')).toEqual<IrcMessage>({
|
|
16
|
+
tags: {},
|
|
17
|
+
source: { name: 'nick', user: 'user', host: 'host' },
|
|
18
|
+
command: 'PRIVMSG',
|
|
19
|
+
params: ['#foo', 'hi'],
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('parses PING with a trailing token', () => {
|
|
24
|
+
expect(parse('PING :tok')).toEqual<IrcMessage>({
|
|
25
|
+
tags: {},
|
|
26
|
+
command: 'PING',
|
|
27
|
+
params: ['tok'],
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('parses a command with no parameters', () => {
|
|
32
|
+
expect(parse('PONG')).toEqual<IrcMessage>({ tags: {}, command: 'PONG', params: [] });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('parses a numeric reply with a server source', () => {
|
|
36
|
+
expect(parse(':irc.example.com 001 nick :Welcome')).toEqual<IrcMessage>({
|
|
37
|
+
tags: {},
|
|
38
|
+
source: { name: 'irc.example.com' },
|
|
39
|
+
command: '001',
|
|
40
|
+
params: ['nick', 'Welcome'],
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('normalizes a lower-case command to upper-case', () => {
|
|
45
|
+
expect(parse('privmsg #foo :hi').command).toBe('PRIVMSG');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('parses multiple middle parameters', () => {
|
|
49
|
+
expect(parse(':nick JOIN #a #b key').params).toEqual(['#a', '#b', 'key']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('parses an empty trailing parameter', () => {
|
|
53
|
+
expect(parse(':nick TOPIC #foo :')).toEqual<IrcMessage>({
|
|
54
|
+
tags: {},
|
|
55
|
+
source: { name: 'nick' },
|
|
56
|
+
command: 'TOPIC',
|
|
57
|
+
params: ['#foo', ''],
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('parses a server source without user or host', () => {
|
|
62
|
+
expect(parse(':irc.example.com NOTICE * :hi').source).toEqual({ name: 'irc.example.com' });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('keeps a colon mid-parameter non-trailing', () => {
|
|
66
|
+
expect(parse('PRIVMSG #foo a:b c').params).toEqual(['#foo', 'a:b', 'c']);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('treats everything after the first space-colon as trailing (literal colons)', () => {
|
|
70
|
+
expect(parse('TOPIC #foo :topic with :colons').params).toEqual(['#foo', 'topic with :colons']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('parses up to 14 middle params plus a trailing param (15 total)', () => {
|
|
74
|
+
const middles = Array.from({ length: 14 }, (_, i) => `p${i}`);
|
|
75
|
+
const line = `PRIVMSG ${middles.join(' ')} :trailing`;
|
|
76
|
+
const parsed = parse(line);
|
|
77
|
+
expect(parsed.params).toEqual([...middles, 'trailing']);
|
|
78
|
+
expect(parsed.params).toHaveLength(15);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('parse — message-tags (IRCv3)', () => {
|
|
83
|
+
it('parses two valued tags', () => {
|
|
84
|
+
expect(parse('@time=2024-01-01T00:00:00Z;account=foo PRIVMSG #foo :hi').tags).toEqual({
|
|
85
|
+
time: '2024-01-01T00:00:00Z',
|
|
86
|
+
account: 'foo',
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('parses a vendor-prefixed tag', () => {
|
|
91
|
+
expect(parse('@+draft/typing=1 PRIVMSG #foo :hi').tags).toEqual({ '+draft/typing': '1' });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('parses a valueless tag as the empty string', () => {
|
|
95
|
+
expect(parse('@+away PRIVMSG #foo :hi').tags).toEqual({ '+away': '' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('decodes the ":" escape to a semicolon', () => {
|
|
99
|
+
expect(parse('@a=foo\\:bar PRIVMSG #foo :hi').tags).toEqual({ a: 'foo;bar' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('decodes the "s" escape to a space', () => {
|
|
103
|
+
expect(parse('@a=foo\\sbar PRIVMSG #foo :hi').tags).toEqual({ a: 'foo bar' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('decodes the "\\\\" escape to a single backslash', () => {
|
|
107
|
+
expect(parse('@a=a\\\\b PRIVMSG #foo :hi').tags).toEqual({ a: 'a\\b' });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('keeps an unknown escape sequence as the literal character', () => {
|
|
111
|
+
expect(parse('@a=x\\qy PRIVMSG #foo :hi').tags).toEqual({ a: 'xqy' });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('skips empty tag entries produced by doubled semicolons', () => {
|
|
115
|
+
expect(parse('@a=b;;c=d PRIVMSG #foo :hi').tags).toEqual({ a: 'b', c: 'd' });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('parses tags together with a source', () => {
|
|
119
|
+
expect(parse('@account=foo :nick!u@h PRIVMSG #foo :hi')).toEqual<IrcMessage>({
|
|
120
|
+
tags: { account: 'foo' },
|
|
121
|
+
source: { name: 'nick', user: 'u', host: 'h' },
|
|
122
|
+
command: 'PRIVMSG',
|
|
123
|
+
params: ['#foo', 'hi'],
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('parse — framing tolerance', () => {
|
|
129
|
+
it('strips a trailing CR LF', () => {
|
|
130
|
+
expect(parse('PING :tok\r\n').params).toEqual(['tok']);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('strips a lone trailing LF', () => {
|
|
134
|
+
expect(parse('PING :tok\n').params).toEqual(['tok']);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('parse — malformed input', () => {
|
|
139
|
+
it('throws IrcParseError on empty input', () => {
|
|
140
|
+
expect(() => parse('')).toThrow(IrcParseError);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('throws IrcParseError on whitespace-only input', () => {
|
|
144
|
+
expect(() => parse(' ')).toThrow(IrcParseError);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('throws IrcParseError when only a source prefix is present', () => {
|
|
148
|
+
expect(() => parse(':nick!user@host')).toThrow(IrcParseError);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('throws IrcParseError when a source is followed by no command', () => {
|
|
152
|
+
expect(() => parse(':irc.example.com ')).toThrow(IrcParseError);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('throws IrcParseError when a tag section has no body', () => {
|
|
156
|
+
expect(() => parse('@time=now')).toThrow(IrcParseError);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('throws IrcParseError on an invalid command token', () => {
|
|
160
|
+
expect(() => parse('12 hi')).toThrow(IrcParseError);
|
|
161
|
+
});
|
|
162
|
+
});
|