serverless-ircd 0.5.0 → 0.6.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 +34 -2
- package/.github/workflows/deploy-aws.yml +1 -2
- package/.github/workflows/deploy-cf-tcp.yml +1 -5
- package/.github/workflows/deploy-cf.yml +1 -2
- package/CHANGELOG.md +260 -0
- package/README.md +132 -22
- package/apps/aws-stack/README.md +33 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +74 -9
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +2 -2
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
- package/apps/aws-stack/tests/vitest.setup.ts +18 -0
- package/apps/aws-stack/vitest.config.ts +3 -0
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/config-loader.ts +6 -7
- package/apps/cf-tcp-container/src/container-server.ts +3 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
- package/apps/cf-tcp-container/wrangler.toml +1 -1
- package/apps/cf-worker/package.json +2 -1
- package/apps/cf-worker/vitest.config.ts +1 -1
- package/apps/cf-worker/wrangler.toml +5 -3
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +6 -6
- package/apps/local-cli/src/main.ts +20 -0
- package/apps/local-cli/src/server.ts +9 -7
- package/apps/local-cli/tests/e2e.test.ts +112 -28
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +12 -7
- package/biome.json +1 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +110 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
- package/docs/ADR-Index.md +15 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1275 -0
- package/docs/AWS-TCP-Deployment.md +410 -0
- package/docs/Cloudflare-Deployment-Guide.md +719 -0
- package/docs/Cloudflare-TCP-Deployment.md +510 -0
- package/docs/Home.md +15 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanExtensions.md +812 -0
- package/docs/PlanIRCv3Websocket.md +503 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/handlers/default.ts +3 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
- package/packages/aws-adapter/src/serialize.ts +29 -1
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
- package/packages/aws-adapter/tests/account-store.test.ts +4 -7
- package/packages/aws-adapter/tests/aws-harness.ts +5 -2
- package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
- package/packages/aws-adapter/tests/aws-runtime.test.ts +79 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +9 -9
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
- package/packages/aws-adapter/tests/global-setup.ts +55 -30
- package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
- package/packages/aws-adapter/tests/handlers.test.ts +90 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +60 -8
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/connection-do.ts +3 -0
- package/packages/cf-adapter/src/serialize.ts +25 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +269 -0
- package/packages/cf-adapter/tests/channel-do.test.ts +117 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +104 -0
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- package/packages/cf-adapter/vitest.config.ts +1 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/caps/capabilities.ts +37 -3
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/caps/sts.ts +84 -0
- package/packages/irc-core/src/commands/account-notify.ts +53 -0
- package/packages/irc-core/src/commands/away.ts +9 -3
- package/packages/irc-core/src/commands/cap.ts +23 -5
- package/packages/irc-core/src/commands/chathistory.ts +40 -11
- package/packages/irc-core/src/commands/index.ts +34 -0
- package/packages/irc-core/src/commands/isupport.ts +53 -0
- package/packages/irc-core/src/commands/mode.ts +8 -1
- package/packages/irc-core/src/commands/monitor.ts +327 -0
- package/packages/irc-core/src/commands/multiline.ts +256 -0
- package/packages/irc-core/src/commands/pre-away.ts +112 -0
- package/packages/irc-core/src/commands/privmsg.ts +19 -12
- package/packages/irc-core/src/commands/read-marker.ts +108 -0
- package/packages/irc-core/src/commands/registration.ts +8 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- package/packages/irc-core/src/commands/tagmsg.ts +71 -1
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +65 -10
- package/packages/irc-core/src/effects.ts +14 -0
- package/packages/irc-core/src/ports.ts +389 -0
- package/packages/irc-core/src/protocol/index.ts +14 -0
- package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
- package/packages/irc-core/src/protocol/numerics.ts +15 -0
- package/packages/irc-core/src/protocol/outbound.ts +16 -1
- package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
- package/packages/irc-core/src/state/connection.ts +32 -1
- package/packages/irc-core/src/types.ts +112 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/away-store.test.ts +73 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
- package/packages/irc-core/tests/caps/sts.test.ts +123 -0
- package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
- package/packages/irc-core/tests/commands/away.test.ts +85 -2
- package/packages/irc-core/tests/commands/cap.test.ts +165 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
- package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
- package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
- package/packages/irc-core/tests/commands/mode.test.ts +72 -0
- package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
- package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
- package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
- package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
- package/packages/irc-core/tests/commands/registration.test.ts +64 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
- package/packages/irc-core/tests/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- package/packages/irc-core/tests/labeled-response.test.ts +181 -0
- package/packages/irc-core/tests/message-tags.test.ts +114 -0
- package/packages/irc-core/tests/monitor-store.test.ts +200 -0
- package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +450 -3
- package/packages/irc-server/src/dispatch.ts +108 -16
- package/packages/irc-server/tests/actor.test.ts +1432 -39
- package/packages/irc-server/tests/dispatch.test.ts +263 -2
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/index.ts +6 -0
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
- package/tools/load-test/package.json +33 -0
- package/tools/load-test/src/client.ts +351 -0
- package/tools/load-test/src/config.ts +313 -0
- package/tools/load-test/src/harness.ts +116 -0
- package/tools/load-test/src/main.ts +120 -0
- package/tools/load-test/src/metrics.ts +168 -0
- package/tools/load-test/src/report.ts +106 -0
- package/tools/load-test/tests/client.test.ts +212 -0
- package/tools/load-test/tests/config.test.ts +152 -0
- package/tools/load-test/tests/framing.test.ts +37 -0
- package/tools/load-test/tests/harness.test.ts +165 -0
- package/tools/load-test/tests/metrics.test.ts +174 -0
- package/tools/load-test/tests/report.test.ts +161 -0
- package/tools/load-test/tests/smoke.test.ts +67 -0
- package/tools/load-test/tsconfig.build.json +12 -0
- package/tools/load-test/tsconfig.test.json +10 -0
- package/tools/load-test/vitest.config.ts +29 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
type ClientResult,
|
|
4
|
+
PHASE_NAMES,
|
|
5
|
+
type PhaseName,
|
|
6
|
+
percentile,
|
|
7
|
+
summarizePhase,
|
|
8
|
+
summarizeResults,
|
|
9
|
+
} from '../src/metrics';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The metrics module is the statistical core of the load tester. It is the
|
|
13
|
+
* piece that turns raw per-client latency samples into the p50/p95/p99
|
|
14
|
+
* summary the report renders, so its math must be pinned independently of
|
|
15
|
+
* any real socket behaviour.
|
|
16
|
+
*/
|
|
17
|
+
describe('percentile (nearest-rank)', () => {
|
|
18
|
+
it('returns the upper-median for p50 of an even-sized sample set', () => {
|
|
19
|
+
// nearest-rank: index = ceil(p/100 * n) - 1
|
|
20
|
+
// n=4, p=50 -> ceil(2)-1 = 1 -> sorted[1] = 20
|
|
21
|
+
expect(percentile([10, 20, 30, 40], 50)).toBe(20);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns the exact middle for p50 of an odd-sized sample set', () => {
|
|
25
|
+
expect(percentile([10, 20, 30], 50)).toBe(20);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns the max for p100', () => {
|
|
29
|
+
expect(percentile([5, 9, 3, 12, 7], 100)).toBe(12);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns the min for p0', () => {
|
|
33
|
+
expect(percentile([5, 9, 3, 12, 7], 0)).toBe(3);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('clamps the rank index to the last element for p99 on a small set', () => {
|
|
37
|
+
// n=3, p=99 -> ceil(2.97)-1 = 3-1 = 2 -> sorted[2] = 90
|
|
38
|
+
expect(percentile([10, 50, 90], 99)).toBe(90);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('throws for a percentile below 0 (programming error)', () => {
|
|
42
|
+
expect(() => percentile([10, 20, 30], -5)).toThrow(/percentile/i);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('works on an unsorted input (sorts internally)', () => {
|
|
46
|
+
expect(percentile([100, 1, 50, 25, 75], 50)).toBe(50);
|
|
47
|
+
expect(percentile([100, 1, 50, 25, 75], 100)).toBe(100);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns 0 for an empty sample set', () => {
|
|
51
|
+
expect(percentile([], 50)).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns the sole sample for a single-element set at any percentile', () => {
|
|
55
|
+
expect(percentile([42], 50)).toBe(42);
|
|
56
|
+
expect(percentile([42], 99)).toBe(42);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('throws for a percentile above 100 (programming error)', () => {
|
|
60
|
+
expect(() => percentile([1, 2, 3], 101)).toThrow(/percentile/i);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('summarizePhase', () => {
|
|
65
|
+
it('reports count and the percentile triple for a populated phase', () => {
|
|
66
|
+
const s = summarizePhase([10, 20, 30, 40, 50, 60, 70, 80, 90, 100]);
|
|
67
|
+
expect(s.count).toBe(10);
|
|
68
|
+
expect(s.min).toBe(10);
|
|
69
|
+
expect(s.max).toBe(100);
|
|
70
|
+
expect(s.p50).toBe(50);
|
|
71
|
+
expect(s.p95).toBe(100);
|
|
72
|
+
expect(s.p99).toBe(100);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns a zeroed summary for an empty sample set', () => {
|
|
76
|
+
const s = summarizePhase([]);
|
|
77
|
+
expect(s).toEqual({ count: 0, min: 0, max: 0, p50: 0, p95: 0, p99: 0 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('handles a single sample', () => {
|
|
81
|
+
expect(summarizePhase([77])).toEqual({
|
|
82
|
+
count: 1,
|
|
83
|
+
min: 77,
|
|
84
|
+
max: 77,
|
|
85
|
+
p50: 77,
|
|
86
|
+
p95: 77,
|
|
87
|
+
p99: 77,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('summarizeResults', () => {
|
|
93
|
+
const emptySamples = (): Record<PhaseName, number[]> => ({
|
|
94
|
+
connect: [],
|
|
95
|
+
register: [],
|
|
96
|
+
join: [],
|
|
97
|
+
privmsg: [],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('computes drop rate as failed / total', () => {
|
|
101
|
+
const results: ClientResult[] = [
|
|
102
|
+
{ id: 0, ok: true, samples: emptySamples() },
|
|
103
|
+
{ id: 1, ok: true, samples: emptySamples() },
|
|
104
|
+
{
|
|
105
|
+
id: 2,
|
|
106
|
+
ok: false,
|
|
107
|
+
error: 'connect timeout',
|
|
108
|
+
failedPhase: 'connect',
|
|
109
|
+
samples: emptySamples(),
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
const summary = summarizeResults(results);
|
|
113
|
+
expect(summary.totalClients).toBe(3);
|
|
114
|
+
expect(summary.succeeded).toBe(2);
|
|
115
|
+
expect(summary.failed).toBe(1);
|
|
116
|
+
expect(summary.dropRate).toBeCloseTo(1 / 3, 5);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('flatten-aggregates samples per phase across all clients (including privmsg repeats)', () => {
|
|
120
|
+
const results: ClientResult[] = [
|
|
121
|
+
{
|
|
122
|
+
id: 0,
|
|
123
|
+
ok: true,
|
|
124
|
+
samples: {
|
|
125
|
+
connect: [12],
|
|
126
|
+
register: [40],
|
|
127
|
+
join: [8],
|
|
128
|
+
privmsg: [3, 4],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 1,
|
|
133
|
+
ok: true,
|
|
134
|
+
samples: {
|
|
135
|
+
connect: [20],
|
|
136
|
+
register: [60],
|
|
137
|
+
join: [10],
|
|
138
|
+
privmsg: [5],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
const summary = summarizeResults(results);
|
|
143
|
+
expect(summary.phases.connect).toEqual(summarizePhase([12, 20]));
|
|
144
|
+
expect(summary.phases.privmsg).toEqual(summarizePhase([3, 4, 5]));
|
|
145
|
+
expect(summary.phases.privmsg.count).toBe(3);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('only counts samples up to the failed phase (drop attribution)', () => {
|
|
149
|
+
// A client that failed at `register` contributes a connect sample but
|
|
150
|
+
// no register/join/privmsg sample.
|
|
151
|
+
const results: ClientResult[] = [
|
|
152
|
+
{
|
|
153
|
+
id: 0,
|
|
154
|
+
ok: false,
|
|
155
|
+
error: 'register timeout',
|
|
156
|
+
failedPhase: 'register',
|
|
157
|
+
samples: { connect: [12], register: [], join: [], privmsg: [] },
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
const summary = summarizeResults(results);
|
|
161
|
+
expect(summary.phases.connect.count).toBe(1);
|
|
162
|
+
expect(summary.phases.register.count).toBe(0);
|
|
163
|
+
expect(summary.phases.join.count).toBe(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('returns a zeroed summary for no clients', () => {
|
|
167
|
+
const summary = summarizeResults([]);
|
|
168
|
+
expect(summary.totalClients).toBe(0);
|
|
169
|
+
expect(summary.dropRate).toBe(0);
|
|
170
|
+
for (const phase of PHASE_NAMES) {
|
|
171
|
+
expect(summary.phases[phase].count).toBe(0);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { type LoadTestConfig, resolveConfig } from '../src/config';
|
|
3
|
+
import {
|
|
4
|
+
type ClientResult,
|
|
5
|
+
type LoadTestSummary,
|
|
6
|
+
PHASE_NAMES,
|
|
7
|
+
summarizeResults,
|
|
8
|
+
} from '../src/metrics';
|
|
9
|
+
import { type LoadTestRun, formatMs, formatPercent, generateReport } from '../src/report';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The report module renders the run's aggregate into a markdown string.
|
|
13
|
+
* Tests pin the structural pieces a human scans for (platform, target,
|
|
14
|
+
* drop rate, the per-phase latency table) without coupling to exact
|
|
15
|
+
* whitespace — the layout can evolve without churn.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function baseConfig(overrides: Partial<LoadTestConfig> = {}): LoadTestConfig {
|
|
19
|
+
return resolveConfig({
|
|
20
|
+
target: 'wss://irc.staging.example.com/',
|
|
21
|
+
platform: 'cf-staging',
|
|
22
|
+
connections: 5,
|
|
23
|
+
channel: '#loadtest',
|
|
24
|
+
...overrides,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function emptySummary(): LoadTestSummary {
|
|
29
|
+
return summarizeResults([]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeRun(
|
|
33
|
+
summary: LoadTestSummary = emptySummary(),
|
|
34
|
+
config: LoadTestConfig = baseConfig(),
|
|
35
|
+
durationMs = 1000,
|
|
36
|
+
): LoadTestRun {
|
|
37
|
+
const startedAt = new Date('2026-08-03T14:00:00.000Z');
|
|
38
|
+
return {
|
|
39
|
+
config,
|
|
40
|
+
summary,
|
|
41
|
+
startedAt,
|
|
42
|
+
finishedAt: new Date(startedAt.getTime() + durationMs),
|
|
43
|
+
durationMs,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function okClient(
|
|
48
|
+
id: number,
|
|
49
|
+
latencies: Partial<Record<(typeof PHASE_NAMES)[number], number[]>>,
|
|
50
|
+
): ClientResult {
|
|
51
|
+
const samples: ClientResult['samples'] = { connect: [], register: [], join: [], privmsg: [] };
|
|
52
|
+
for (const [phase, vals] of Object.entries(latencies) as [keyof typeof samples, number[]][]) {
|
|
53
|
+
samples[phase] = vals;
|
|
54
|
+
}
|
|
55
|
+
return { id, ok: true, samples };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('formatMs', () => {
|
|
59
|
+
it('formats sub-second durations in ms', () => {
|
|
60
|
+
expect(formatMs(12)).toBe('12.00ms');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('formats seconds with two decimals', () => {
|
|
64
|
+
expect(formatMs(42_300)).toBe('42.30s');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('formats minutes with seconds', () => {
|
|
68
|
+
expect(formatMs(90_000)).toBe('1m30.00s');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('formats zero as 0.00ms', () => {
|
|
72
|
+
expect(formatMs(0)).toBe('0.00ms');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('formatPercent', () => {
|
|
77
|
+
it('formats a fraction as a percentage with two decimals', () => {
|
|
78
|
+
expect(formatPercent(0.0002)).toBe('0.02%');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('formats 1 as 100.00%', () => {
|
|
82
|
+
expect(formatPercent(1)).toBe('100.00%');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('formats 0 as 0.00%', () => {
|
|
86
|
+
expect(formatPercent(0)).toBe('0.00%');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('generateReport', () => {
|
|
91
|
+
it('includes the platform label and target URL in the header', () => {
|
|
92
|
+
const md = generateReport(makeRun());
|
|
93
|
+
expect(md).toContain('cf-staging');
|
|
94
|
+
expect(md).toContain('wss://irc.staging.example.com/');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('includes the ISO date and the human-readable duration', () => {
|
|
98
|
+
const md = generateReport(makeRun(emptySummary(), baseConfig(), 42_300));
|
|
99
|
+
expect(md).toContain('2026-08-03T14:00:00.000Z');
|
|
100
|
+
expect(md).toContain('42.30s');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('reports the configured connection count, channel, and messages/client', () => {
|
|
104
|
+
const cfg = baseConfig({ connections: 10_000, channel: '#perf', messagesPerClient: 3 });
|
|
105
|
+
const md = generateReport(makeRun(emptySummary(), cfg));
|
|
106
|
+
expect(md).toContain('10000');
|
|
107
|
+
expect(md).toContain('#perf');
|
|
108
|
+
expect(md).toContain('3');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('renders the drop rate from the summary', () => {
|
|
112
|
+
const results: ClientResult[] = [
|
|
113
|
+
okClient(0, { connect: [10] }),
|
|
114
|
+
okClient(1, { connect: [12] }),
|
|
115
|
+
{
|
|
116
|
+
id: 2,
|
|
117
|
+
ok: false,
|
|
118
|
+
error: 'timeout',
|
|
119
|
+
failedPhase: 'connect',
|
|
120
|
+
samples: { connect: [], register: [], join: [], privmsg: [] },
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
const run = makeRun(summarizeResults(results));
|
|
124
|
+
const md = generateReport(run);
|
|
125
|
+
expect(md).toContain('Attempted');
|
|
126
|
+
expect(md).toContain('3');
|
|
127
|
+
expect(md).toContain('2'); // succeeded
|
|
128
|
+
expect(md).toContain('1'); // failed
|
|
129
|
+
expect(md).toContain('33.33%'); // drop rate
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('renders a latency table row per phase with p50/p95/p99 columns', () => {
|
|
133
|
+
const results: ClientResult[] = [
|
|
134
|
+
okClient(0, { connect: [12, 20], register: [40], join: [8], privmsg: [3, 4] }),
|
|
135
|
+
okClient(1, { connect: [30], register: [60], join: [10], privmsg: [5] }),
|
|
136
|
+
];
|
|
137
|
+
const md = generateReport(makeRun(summarizeResults(results)));
|
|
138
|
+
// Column headers present.
|
|
139
|
+
expect(md).toContain('p50');
|
|
140
|
+
expect(md).toContain('p95');
|
|
141
|
+
expect(md).toContain('p99');
|
|
142
|
+
// Each phase name appears as a row.
|
|
143
|
+
for (const phase of PHASE_NAMES) {
|
|
144
|
+
expect(md).toContain(`| ${phase} `);
|
|
145
|
+
}
|
|
146
|
+
// Sample counts reach the table.
|
|
147
|
+
expect(md).toContain('| connect |'); // row marker
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('renders a zeroed latency table when no clients succeeded', () => {
|
|
151
|
+
const md = generateReport(makeRun(emptySummary()));
|
|
152
|
+
for (const phase of PHASE_NAMES) {
|
|
153
|
+
expect(md).toContain(`| ${phase} `);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('documents that cost modeling is platform-specific (operator-filled)', () => {
|
|
158
|
+
const md = generateReport(makeRun());
|
|
159
|
+
expect(md.toLowerCase()).toContain('cost');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type LocalServer, startLocalServer } from '@serverless-ircd/local-cli/dist/server.js';
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
3
|
+
import { resolveConfig } from '../src/config';
|
|
4
|
+
import { runLoadTest } from '../src/harness';
|
|
5
|
+
import { generateReport } from '../src/report';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Smoke acceptance: "load test script completes without crashing."
|
|
9
|
+
*
|
|
10
|
+
* This is the end-to-end smoke: the real `IrcLoadClient` factory, a real
|
|
11
|
+
* in-process IRC server (`startLocalServer`, the project's e2e fixture
|
|
12
|
+
* target), and a small fanout of real WebSocket connections through the
|
|
13
|
+
* full lifecycle. Sizes are kept tiny so the suite stays fast; the same
|
|
14
|
+
* machinery scales to 10k by changing `--connections` on the CLI.
|
|
15
|
+
*/
|
|
16
|
+
describe('load-test smoke (real IRC server)', () => {
|
|
17
|
+
let irc: LocalServer;
|
|
18
|
+
let target: string;
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
irc = await startLocalServer({
|
|
22
|
+
port: 0,
|
|
23
|
+
hostname: '127.0.0.1',
|
|
24
|
+
serverName: 'irc.load-test.local',
|
|
25
|
+
networkName: 'LoadTestNet',
|
|
26
|
+
});
|
|
27
|
+
target = `ws://127.0.0.1:${irc.port}/`;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(async () => {
|
|
31
|
+
await irc.close();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('completes a small fanout with no crashes and sane metrics', async () => {
|
|
35
|
+
const channel = `#smoke-${Math.random().toString(36).slice(2, 8)}`;
|
|
36
|
+
const config = resolveConfig({
|
|
37
|
+
target,
|
|
38
|
+
connections: 6,
|
|
39
|
+
connectConcurrency: 3,
|
|
40
|
+
rampMs: 0,
|
|
41
|
+
channel,
|
|
42
|
+
nickPrefix: 'smoke',
|
|
43
|
+
messagesPerClient: 2,
|
|
44
|
+
messageIntervalMs: 0,
|
|
45
|
+
platform: 'local-cli-smoke',
|
|
46
|
+
connectTimeoutMs: 3000,
|
|
47
|
+
registerTimeoutMs: 3000,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const run = await runLoadTest(config);
|
|
51
|
+
|
|
52
|
+
// Every client should succeed against the healthy in-process server.
|
|
53
|
+
expect(run.summary.totalClients).toBe(6);
|
|
54
|
+
expect(run.summary.succeeded).toBe(6);
|
|
55
|
+
expect(run.summary.failed).toBe(0);
|
|
56
|
+
expect(run.summary.dropRate).toBe(0);
|
|
57
|
+
// Each phase that was reached has samples.
|
|
58
|
+
expect(run.summary.phases.connect.count).toBe(6);
|
|
59
|
+
expect(run.summary.phases.register.count).toBe(6);
|
|
60
|
+
expect(run.summary.phases.join.count).toBe(6);
|
|
61
|
+
expect(run.summary.phases.privmsg.count).toBe(6 * 2);
|
|
62
|
+
// The report renders without throwing and mentions the platform.
|
|
63
|
+
const md = generateReport(run);
|
|
64
|
+
expect(md).toContain('local-cli-smoke');
|
|
65
|
+
expect(md).toContain('Latency');
|
|
66
|
+
}, 20_000);
|
|
67
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"composite": true,
|
|
5
|
+
"rootDir": "./src",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"tsBuildInfoFile": "./.tsbuildinfo",
|
|
8
|
+
"types": ["node"]
|
|
9
|
+
},
|
|
10
|
+
"include": ["src/**/*.ts"],
|
|
11
|
+
"exclude": ["dist", "tests", "**/*.test.ts"]
|
|
12
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
environment: 'node',
|
|
6
|
+
include: ['tests/**/*.test.ts'],
|
|
7
|
+
// The harness smoke test spins up a real in-process WS IRC server
|
|
8
|
+
// (via @serverless-ircd/local-cli) and ramps a small fanout of real
|
|
9
|
+
// WebSocket connections against it; give it headroom while staying
|
|
10
|
+
// fast enough for watch mode.
|
|
11
|
+
testTimeout: 15_000,
|
|
12
|
+
hookTimeout: 15_000,
|
|
13
|
+
coverage: {
|
|
14
|
+
provider: 'v8',
|
|
15
|
+
all: false,
|
|
16
|
+
include: ['src/**/*.ts'],
|
|
17
|
+
// `main.ts` is CLI glue (argv parsing, signal handlers); exercised
|
|
18
|
+
// manually by `pnpm start`. Mirrors the apps/local-cli policy.
|
|
19
|
+
exclude: ['src/main.ts'],
|
|
20
|
+
reporter: ['text', 'html', 'json-summary'],
|
|
21
|
+
thresholds: {
|
|
22
|
+
lines: 90,
|
|
23
|
+
functions: 90,
|
|
24
|
+
branches: 80,
|
|
25
|
+
statements: 90,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/tcp-ws-forwarder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|