llm-mock-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.github/dependabot.yml +11 -0
  2. package/.github/workflows/test.yml +34 -0
  3. package/.markdownlint.jsonc +11 -0
  4. package/.node-version +1 -0
  5. package/.oxlintrc.json +35 -0
  6. package/ARCHITECTURE.md +125 -0
  7. package/LICENCE +21 -0
  8. package/README.md +448 -0
  9. package/package.json +55 -0
  10. package/src/cli-validators.ts +56 -0
  11. package/src/cli.ts +128 -0
  12. package/src/formats/anthropic/index.ts +14 -0
  13. package/src/formats/anthropic/parse.ts +48 -0
  14. package/src/formats/anthropic/schema.ts +133 -0
  15. package/src/formats/anthropic/serialize.ts +91 -0
  16. package/src/formats/openai/index.ts +14 -0
  17. package/src/formats/openai/parse.ts +34 -0
  18. package/src/formats/openai/schema.ts +147 -0
  19. package/src/formats/openai/serialize.ts +92 -0
  20. package/src/formats/parse-helpers.ts +79 -0
  21. package/src/formats/responses/index.ts +14 -0
  22. package/src/formats/responses/parse.ts +56 -0
  23. package/src/formats/responses/schema.ts +143 -0
  24. package/src/formats/responses/serialize.ts +129 -0
  25. package/src/formats/types.ts +17 -0
  26. package/src/history.ts +66 -0
  27. package/src/index.ts +44 -0
  28. package/src/loader.ts +213 -0
  29. package/src/logger.ts +58 -0
  30. package/src/mock-server.ts +237 -0
  31. package/src/route-handler.ts +113 -0
  32. package/src/rule-engine.ts +119 -0
  33. package/src/sse-writer.ts +35 -0
  34. package/src/types/index.ts +4 -0
  35. package/src/types/reply.ts +49 -0
  36. package/src/types/request.ts +45 -0
  37. package/src/types/rule.ts +74 -0
  38. package/src/types.ts +5 -0
  39. package/test/cli-validators.test.ts +131 -0
  40. package/test/formats/anthropic-schema.test.ts +192 -0
  41. package/test/formats/anthropic.test.ts +260 -0
  42. package/test/formats/openai-schema.test.ts +105 -0
  43. package/test/formats/openai.test.ts +243 -0
  44. package/test/formats/responses-schema.test.ts +114 -0
  45. package/test/formats/responses.test.ts +299 -0
  46. package/test/loader.test.ts +314 -0
  47. package/test/mock-server.test.ts +565 -0
  48. package/test/rule-engine.test.ts +213 -0
  49. package/tsconfig.json +26 -0
  50. package/tsconfig.test.json +11 -0
  51. package/vitest.config.ts +18 -0
@@ -0,0 +1,113 @@
1
+ import type { FastifyReply, FastifyRequest } from "fastify";
2
+ import { ZodError } from "zod";
3
+ import type { Reply, ReplyObject, ReplyOptions, MockRequest, Rule } from "./types.js";
4
+ import type { Format } from "./formats/types.js";
5
+ import type { RuleEngine } from "./rule-engine.js";
6
+ import type { RequestHistory } from "./history.js";
7
+ import type { Logger } from "./logger.js";
8
+ import { writeSSE } from "./sse-writer.js";
9
+
10
+ const HTTP_BAD_REQUEST = 400;
11
+
12
+ function normalizeReply(reply: Reply): ReplyObject {
13
+ if (typeof reply === "string") return { text: reply };
14
+ return reply;
15
+ }
16
+
17
+ async function resolveReply(
18
+ matched: Rule | undefined,
19
+ mockReq: MockRequest,
20
+ fallback: Reply,
21
+ logger: Logger,
22
+ ): Promise<{ reply: ReplyObject; ruleDesc: string | undefined }> {
23
+ if (!matched) {
24
+ logger.warn(`No matching rule for "${mockReq.lastMessage}", using fallback`);
25
+ return { reply: normalizeReply(fallback), ruleDesc: undefined };
26
+ }
27
+
28
+ try {
29
+ const raw = typeof matched.resolve === "function"
30
+ ? await matched.resolve(mockReq)
31
+ : matched.resolve;
32
+ logger.debug(`Matched rule ${matched.description}`);
33
+ return { reply: normalizeReply(raw), ruleDesc: matched.description };
34
+ } catch (err) {
35
+ logger.error(`Resolver threw for rule ${matched.description}`, err);
36
+ return { reply: normalizeReply(fallback), ruleDesc: matched.description };
37
+ }
38
+ }
39
+
40
+ export interface RouteHandlerDeps {
41
+ engine: RuleEngine;
42
+ history: RequestHistory;
43
+ logger: Logger;
44
+ defaultOptions: ReplyOptions;
45
+ getFallback: () => Reply;
46
+ }
47
+
48
+ export function createRouteHandler(format: Format, deps: RouteHandlerDeps): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {
49
+ const { engine, history, logger, defaultOptions, getFallback } = deps;
50
+
51
+ return async (request: FastifyRequest, reply: FastifyReply) => {
52
+ const body = request.body;
53
+ const headers: Record<string, string | undefined> = {};
54
+ for (const [key, val] of Object.entries(request.headers)) {
55
+ headers[key] = Array.isArray(val) ? val.join(", ") : val;
56
+ }
57
+ const meta = { headers, path: request.url };
58
+
59
+ let mockReq: MockRequest;
60
+ try {
61
+ mockReq = format.parseRequest(body, meta);
62
+ } catch (err) {
63
+ if (err instanceof ZodError) {
64
+ logger.warn(`Invalid ${format.name} request: ${err.issues.map((i) => i.message).join(", ")}`);
65
+ return reply.status(HTTP_BAD_REQUEST).type("application/json").send(
66
+ format.serializeError({ status: HTTP_BAD_REQUEST, message: "Invalid request body", type: "invalid_request_error" }),
67
+ );
68
+ }
69
+ throw err;
70
+ }
71
+ const startTime = Date.now();
72
+
73
+ logger.debug(
74
+ `${format.name} request: model=${mockReq.model} streaming=${mockReq.streaming} messages=${mockReq.messages.length}`,
75
+ );
76
+
77
+ const matched = engine.match(mockReq);
78
+ const { reply: resolvedReply, ruleDesc } = await resolveReply(
79
+ matched, mockReq, getFallback(), logger,
80
+ );
81
+
82
+ if (resolvedReply.error) {
83
+ const { error } = resolvedReply;
84
+ logger.info(`Error reply: ${String(error.status)} ${error.message}`);
85
+ history.record(mockReq, ruleDesc);
86
+ return reply.status(error.status).type("application/json").send(format.serializeError(error));
87
+ }
88
+
89
+ history.record(mockReq, ruleDesc);
90
+
91
+ const isStreaming = format.isStreaming(body);
92
+ const effectiveOptions = { ...defaultOptions, ...matched?.options };
93
+ const elapsed = Date.now() - startTime;
94
+ const mode = isStreaming ? "stream" : "json";
95
+
96
+ logger.info(
97
+ `POST ${format.route} [${mode}] "${mockReq.lastMessage}" -> ${ruleDesc ?? "fallback"} (${elapsed}ms)`,
98
+ );
99
+ if (resolvedReply.text) {
100
+ logger.debug(`Reply text: "${resolvedReply.text}"`);
101
+ }
102
+ if (resolvedReply.tools?.length) {
103
+ logger.debug(`Reply tool calls: ${resolvedReply.tools.map((t) => t.name).join(", ")}`);
104
+ }
105
+
106
+ if (!isStreaming) {
107
+ return reply.type("application/json").send(format.serializeComplete(resolvedReply, mockReq.model));
108
+ }
109
+
110
+ const chunks = format.serialize(resolvedReply, mockReq.model, effectiveOptions);
111
+ await writeSSE(reply, chunks, effectiveOptions);
112
+ };
113
+ }
@@ -0,0 +1,119 @@
1
+ import type { Match, MatchObject, MockRequest, Resolver, ReplyOptions, Rule, RuleSummary } from "./types.js";
2
+
3
+ function safeRegex(re: RegExp): RegExp {
4
+ return (re.global || re.sticky) ? new RegExp(re.source, re.flags.replace(/[gy]/g, "")) : re;
5
+ }
6
+
7
+ function compilePattern(pattern: string | RegExp): (value: string) => boolean {
8
+ if (typeof pattern === "string") {
9
+ const lower = pattern.toLowerCase();
10
+ return (value) => value.toLowerCase().includes(lower);
11
+ }
12
+ const re = safeRegex(pattern);
13
+ return (value) => re.test(value);
14
+ }
15
+
16
+ function compileMatcher(match: Match): (req: MockRequest) => boolean {
17
+ if (typeof match === "string") {
18
+ const test = compilePattern(match);
19
+ return (req) => test(req.lastMessage);
20
+ }
21
+ if (match instanceof RegExp) {
22
+ const test = compilePattern(match);
23
+ return (req) => test(req.lastMessage);
24
+ }
25
+ if (typeof match === "function") {
26
+ return match;
27
+ }
28
+ const obj = match;
29
+ const messageTest = obj.message !== undefined ? compilePattern(obj.message) : undefined;
30
+ const modelTest = obj.model !== undefined ? compilePattern(obj.model) : undefined;
31
+ const systemTest = obj.system !== undefined ? compilePattern(obj.system) : undefined;
32
+ return (req) => {
33
+ if (messageTest && !messageTest(req.lastMessage)) return false;
34
+ if (modelTest && !modelTest(req.model)) return false;
35
+ if (systemTest && !systemTest(req.systemMessage)) return false;
36
+ if (obj.format !== undefined && req.format !== obj.format) return false;
37
+ if (obj.toolName !== undefined && !req.toolNames.includes(obj.toolName)) return false;
38
+ if (obj.toolCallId !== undefined && req.lastToolCallId !== obj.toolCallId) return false;
39
+ if (obj.predicate && !obj.predicate(req)) return false;
40
+ return true;
41
+ };
42
+ }
43
+
44
+ function describeMatch(match: Match): string {
45
+ if (typeof match === "string") return `"${match}"`;
46
+ if (match instanceof RegExp) return match.toString();
47
+ if (typeof match === "function") return "(predicate)";
48
+ const obj: MatchObject = match;
49
+ const parts = Object.entries(obj)
50
+ .filter(([, v]) => v !== undefined && typeof v !== "function")
51
+ .map(([k, v]) => `${k}=${String(v)}`);
52
+ return `{${parts.join(", ")}}`;
53
+ }
54
+
55
+ function createRule(match: Match, resolve: Resolver, options: ReplyOptions, description?: string): Rule {
56
+ return {
57
+ description: description ?? describeMatch(match),
58
+ match: compileMatcher(match),
59
+ resolve,
60
+ options,
61
+ remaining: Infinity,
62
+ };
63
+ }
64
+
65
+ export class RuleEngine {
66
+ private readonly rules: Rule[] = [];
67
+
68
+ add(match: Match, resolve: Resolver, options: ReplyOptions = {}): Rule {
69
+ const rule = createRule(match, resolve, options);
70
+ this.rules.push(rule);
71
+ return rule;
72
+ }
73
+
74
+ moveToFront(rule: Rule): void {
75
+ const idx = this.rules.indexOf(rule);
76
+ if (idx > 0) {
77
+ this.rules.splice(idx, 1);
78
+ this.rules.unshift(rule);
79
+ }
80
+ }
81
+
82
+ addHandler(matchFn: (req: MockRequest) => boolean, respond: Resolver, description = "(handler)"): Rule {
83
+ const rule = createRule(matchFn, respond, {}, description);
84
+ this.rules.push(rule);
85
+ return rule;
86
+ }
87
+
88
+ match(req: MockRequest): Rule | undefined {
89
+ for (let i = 0; i < this.rules.length; i++) {
90
+ const rule = this.rules[i]!;
91
+
92
+ if (rule.remaining <= 0) continue;
93
+ if (!rule.match(req)) continue;
94
+
95
+ rule.remaining--;
96
+ if (rule.remaining <= 0) {
97
+ this.rules.splice(i, 1);
98
+ }
99
+ return rule;
100
+ }
101
+ return undefined;
102
+ }
103
+
104
+ isDone(): boolean {
105
+ return this.rules.every((r) => !Number.isFinite(r.remaining) || r.remaining <= 0);
106
+ }
107
+
108
+ get ruleCount(): number {
109
+ return this.rules.length;
110
+ }
111
+
112
+ describe(): readonly RuleSummary[] {
113
+ return this.rules.map((r) => ({ description: r.description, remaining: r.remaining }));
114
+ }
115
+
116
+ clear(): void {
117
+ this.rules.length = 0;
118
+ }
119
+ }
@@ -0,0 +1,35 @@
1
+ import type { FastifyReply } from "fastify";
2
+ import type { SSEChunk } from "./formats/types.js";
3
+ import type { ReplyOptions } from "./types.js";
4
+
5
+ const HTTP_OK = 200;
6
+
7
+ function sleep(ms: number): Promise<void> {
8
+ return new Promise((resolve) => setTimeout(resolve, ms));
9
+ }
10
+
11
+ function formatSSEChunk(chunk: SSEChunk): string {
12
+ const eventLine = chunk.event ? `event: ${chunk.event}\n` : "";
13
+ return `${eventLine}data: ${chunk.data}\n\n`;
14
+ }
15
+
16
+ export async function writeSSE(
17
+ reply: FastifyReply,
18
+ chunks: readonly SSEChunk[],
19
+ options: ReplyOptions = {},
20
+ ): Promise<void> {
21
+ const latency = options.latency ?? 0;
22
+
23
+ reply.raw.writeHead(HTTP_OK, {
24
+ "Content-Type": "text/event-stream",
25
+ "Cache-Control": "no-cache",
26
+ Connection: "keep-alive",
27
+ });
28
+
29
+ for (const chunk of chunks) {
30
+ reply.raw.write(formatSSEChunk(chunk));
31
+ if (latency > 0) await sleep(latency);
32
+ }
33
+
34
+ reply.raw.end();
35
+ }
@@ -0,0 +1,4 @@
1
+ export type { FormatName, MockRequest, Message, ToolDef } from "./request.js";
2
+ export type { Reply, ReplyObject, ErrorReply, ToolCall, Resolver, ReplyOptions, SequenceEntry } from "./reply.js";
3
+ export type { Match, MatchObject, PendingRule, RuleHandle, RuleSummary, Handler, Rule } from "./rule.js";
4
+ export type { RecordedRequest } from "../history.js";
@@ -0,0 +1,49 @@
1
+ import type { MockRequest } from "./request.js";
2
+
3
+ /** A reply is either a plain string (turns into `{ text: "..." }`) or a full reply object. */
4
+ export type Reply = string | ReplyObject;
5
+
6
+ /** A structured reply. Text, reasoning, tool calls, usage, and errors are all optional. */
7
+ export interface ReplyObject {
8
+ /** Text content to send back. */
9
+ readonly text?: string | undefined;
10
+ /** Extended thinking or chain-of-thought. Works with Anthropic and Responses formats. */
11
+ readonly reasoning?: string | undefined;
12
+ /** Tool calls the "model" wants to make. */
13
+ readonly tools?: readonly ToolCall[] | undefined;
14
+ /** Token counts to report. Falls back to `{ input: 10, output: 5 }` if omitted. */
15
+ readonly usage?: { readonly input: number; readonly output: number } | undefined;
16
+ /** When set, the server responds with this HTTP error instead of a normal reply. */
17
+ readonly error?: ErrorReply | undefined;
18
+ }
19
+
20
+ /** An HTTP error response. The server returns this status code with a format-appropriate body. */
21
+ export interface ErrorReply {
22
+ /** HTTP status code, like 429 or 500. */
23
+ readonly status: number;
24
+ readonly message: string;
25
+ /** Error type string in the response body. Each format has its own default if omitted. */
26
+ readonly type?: string | undefined;
27
+ }
28
+
29
+ /** A tool call in the mock response. */
30
+ export interface ToolCall {
31
+ /** Explicit ID for the call. Auto-generated if omitted. */
32
+ readonly id?: string | undefined;
33
+ readonly name: string;
34
+ readonly args: Readonly<Record<string, unknown>>;
35
+ }
36
+
37
+ /** A reply value or a function that produces one. Async functions are supported. */
38
+ export type Resolver = Reply | ((req: MockRequest) => Reply | Promise<Reply>);
39
+
40
+ /** Streaming options for a rule. Merged with server-level defaults, with per-rule values winning. */
41
+ export interface ReplyOptions {
42
+ /** Milliseconds to wait between SSE chunks. */
43
+ readonly latency?: number | undefined;
44
+ /** Split text into chunks of this many characters for more realistic streaming. */
45
+ readonly chunkSize?: number | undefined;
46
+ }
47
+
48
+ /** A single entry in a reply sequence. Can be a plain reply or a reply with per-step options. */
49
+ export type SequenceEntry = Reply | { readonly reply: Reply; readonly options?: ReplyOptions };
@@ -0,0 +1,45 @@
1
+ /** The LLM API wire format that was detected for a request. */
2
+ export type FormatName = "openai" | "anthropic" | "responses";
3
+
4
+ /** A normalised view of an incoming request, regardless of the original wire format. */
5
+ export interface MockRequest {
6
+ /** Which format route the request hit. */
7
+ readonly format: FormatName;
8
+ readonly model: string;
9
+ /** Whether the client asked for SSE streaming. */
10
+ readonly streaming: boolean;
11
+ /** Full conversation, normalised from whatever format came in. */
12
+ readonly messages: readonly Message[];
13
+ /** The last user message's text. This is what most matchers check. */
14
+ readonly lastMessage: string;
15
+ /** System prompt text, or `""` if there wasn't one. */
16
+ readonly systemMessage: string;
17
+ /** Tool definitions from the request, if any were sent. */
18
+ readonly tools?: readonly ToolDef[] | undefined;
19
+ /** The names from `tools`, pulled out for quick lookups. */
20
+ readonly toolNames: readonly string[];
21
+ /** If the last message was a tool result, this is its `tool_call_id`. */
22
+ readonly lastToolCallId: string | undefined;
23
+ /** The raw request body, in case you need something we don't extract. */
24
+ readonly raw: unknown;
25
+ /** HTTP headers from the incoming request. */
26
+ readonly headers: Readonly<Record<string, string | undefined>>;
27
+ /** The URL path that was hit, e.g. `/v1/chat/completions`. */
28
+ readonly path: string;
29
+ }
30
+
31
+ /** A single conversation message, normalised across all supported formats. */
32
+ export interface Message {
33
+ readonly role: "system" | "user" | "assistant" | "tool";
34
+ readonly content: string;
35
+ /** Only set on `"tool"` messages. Links the result back to its tool call. */
36
+ readonly toolCallId?: string | undefined;
37
+ }
38
+
39
+ /** A tool definition from the request's `tools` array, normalised across formats. */
40
+ export interface ToolDef {
41
+ readonly name: string;
42
+ readonly description?: string | undefined;
43
+ /** JSON Schema for the tool's parameters, passed through as-is. */
44
+ readonly parameters?: unknown;
45
+ }
@@ -0,0 +1,74 @@
1
+ import type { MockRequest, FormatName } from "./request.js";
2
+ import type { Resolver, ReplyOptions, Reply, SequenceEntry } from "./reply.js";
3
+
4
+ /**
5
+ * Determines whether a rule matches an incoming request.
6
+ *
7
+ * A `string` does a case-insensitive substring match on the last user message.
8
+ * A `RegExp` gets tested against the last user message.
9
+ * A `MatchObject` checks multiple fields at once with AND logic.
10
+ * A function receives the normalised request and returns a boolean.
11
+ */
12
+ export type Match =
13
+ | string
14
+ | RegExp
15
+ | MatchObject
16
+ | ((req: MockRequest) => boolean);
17
+
18
+ /** A structured matcher. Every field you set must match for the rule to fire. */
19
+ export interface MatchObject {
20
+ /** Substring or regex against the last user message. */
21
+ readonly message?: string | RegExp;
22
+ /** Substring or regex against the model name. */
23
+ readonly model?: string | RegExp;
24
+ /** Substring or regex against the system prompt. */
25
+ readonly system?: string | RegExp;
26
+ /** Only match requests from this API format. */
27
+ readonly format?: FormatName;
28
+ /** Match when the request includes a tool definition with this name. */
29
+ readonly toolName?: string;
30
+ /** Match when the last tool-result message has this `tool_call_id`. */
31
+ readonly toolCallId?: string;
32
+ /** Extra check that runs after all other fields pass. */
33
+ readonly predicate?: (req: MockRequest) => boolean;
34
+ }
35
+
36
+ /** Returned by `when()`. Call `.reply()` or `.replySequence()` on it to complete the rule. */
37
+ export interface PendingRule {
38
+ reply(response: Resolver, options?: ReplyOptions): RuleHandle;
39
+ /** Each match advances through the array. The last entry repeats once the sequence is exhausted. */
40
+ replySequence(entries: readonly SequenceEntry[]): RuleHandle;
41
+ }
42
+
43
+ /** A handle to a registered rule. All methods return `this` for chaining. */
44
+ export interface RuleHandle {
45
+ /** Auto-expire the rule after `n` matches. */
46
+ times(n: number): RuleHandle;
47
+ /** Move this rule to the front of the list so it matches first. */
48
+ first(): RuleHandle;
49
+ }
50
+
51
+ /**
52
+ * The shape of a handler file's default export.
53
+ * You can export a single handler or an array of them.
54
+ */
55
+ export interface Handler {
56
+ match: (req: MockRequest) => boolean;
57
+ respond: (req: MockRequest) => Reply | Promise<Reply>;
58
+ }
59
+
60
+ /** A summary of a registered rule, for inspection. */
61
+ export interface RuleSummary {
62
+ /** Human-readable description of what the rule matches. */
63
+ readonly description: string;
64
+ /** How many matches are left. `Infinity` means unlimited. */
65
+ readonly remaining: number;
66
+ }
67
+
68
+ export interface Rule {
69
+ readonly description: string;
70
+ readonly match: (req: MockRequest) => boolean;
71
+ readonly resolve: Resolver;
72
+ options: ReplyOptions;
73
+ remaining: number;
74
+ }
package/src/types.ts ADDED
@@ -0,0 +1,5 @@
1
+ export type {
2
+ FormatName, MockRequest, Message, ToolDef,
3
+ Reply, ReplyObject, ErrorReply, ToolCall, Resolver, ReplyOptions, SequenceEntry,
4
+ Match, MatchObject, PendingRule, RuleHandle, RuleSummary, Handler, RecordedRequest, Rule,
5
+ } from "./types/index.js";
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parsePort, parseHost, parseChunkSize, parseLogLevel, parseLatency } from "../src/cli-validators.js";
3
+
4
+ describe("parsePort", () => {
5
+ it("parses a valid port", () => {
6
+ expect(parsePort("5555")).toBe(5555);
7
+ });
8
+
9
+ it("accepts port 1", () => {
10
+ expect(parsePort("1")).toBe(1);
11
+ });
12
+
13
+ it("accepts port 65535", () => {
14
+ expect(parsePort("65535")).toBe(65535);
15
+ });
16
+
17
+ it("throws on port 0", () => {
18
+ expect(() => parsePort("0")).toThrow('Invalid port "0"');
19
+ });
20
+
21
+ it("throws on port above 65535", () => {
22
+ expect(() => parsePort("65536")).toThrow('Invalid port "65536"');
23
+ });
24
+
25
+ it("throws on negative port", () => {
26
+ expect(() => parsePort("-1")).toThrow('Invalid port "-1"');
27
+ });
28
+
29
+ it("throws on non-numeric string", () => {
30
+ expect(() => parsePort("abc")).toThrow('Invalid port "abc"');
31
+ });
32
+
33
+ it("throws on empty string", () => {
34
+ expect(() => parsePort("")).toThrow('Invalid port ""');
35
+ });
36
+
37
+ it("truncates floating point to integer", () => {
38
+ expect(parsePort("80.5")).toBe(80);
39
+ });
40
+ });
41
+
42
+ describe("parseLogLevel", () => {
43
+ it.each(["none", "error", "warning", "info", "debug", "all"] as const)(
44
+ "accepts %s",
45
+ (level) => {
46
+ expect(parseLogLevel(level)).toBe(level);
47
+ },
48
+ );
49
+
50
+ it("throws on invalid level", () => {
51
+ expect(() => parseLogLevel("verbose")).toThrow('Invalid log level "verbose"');
52
+ });
53
+
54
+ it("throws on empty string", () => {
55
+ expect(() => parseLogLevel("")).toThrow('Invalid log level ""');
56
+ });
57
+ });
58
+
59
+ describe("parseHost", () => {
60
+ it("accepts 127.0.0.1", async () => {
61
+ await expect(parseHost("127.0.0.1")).resolves.toBe("127.0.0.1");
62
+ });
63
+
64
+ it("accepts 0.0.0.0", async () => {
65
+ await expect(parseHost("0.0.0.0")).resolves.toBe("0.0.0.0");
66
+ });
67
+
68
+ it("accepts localhost", async () => {
69
+ await expect(parseHost("localhost")).resolves.toBe("localhost");
70
+ });
71
+
72
+ it("accepts an IPv6 address", async () => {
73
+ await expect(parseHost("::1")).resolves.toBe("::1");
74
+ });
75
+
76
+ it("rejects an empty string", async () => {
77
+ await expect(parseHost("")).rejects.toThrow('Invalid host ""');
78
+ });
79
+
80
+ it("rejects an unresolvable hostname", async () => {
81
+ await expect(parseHost("not.a.real.host.invalid")).rejects.toThrow("Invalid host");
82
+ });
83
+
84
+ it("rejects a string with spaces", async () => {
85
+ await expect(parseHost("local host")).rejects.toThrow('Invalid host "local host"');
86
+ });
87
+ });
88
+
89
+ describe("parseLatency", () => {
90
+ it("parses a valid latency", () => {
91
+ expect(parseLatency("100")).toBe(100);
92
+ });
93
+
94
+ it("accepts zero", () => {
95
+ expect(parseLatency("0")).toBe(0);
96
+ });
97
+
98
+ it("throws on negative value", () => {
99
+ expect(() => parseLatency("-1")).toThrow('Invalid latency "-1"');
100
+ });
101
+
102
+ it("throws on non-numeric value", () => {
103
+ expect(() => parseLatency("abc")).toThrow('Invalid latency "abc"');
104
+ });
105
+
106
+ it("throws on empty string", () => {
107
+ expect(() => parseLatency("")).toThrow('Invalid latency ""');
108
+ });
109
+
110
+ it("truncates floating point to integer", () => {
111
+ expect(parseLatency("50.7")).toBe(50);
112
+ });
113
+ });
114
+
115
+ describe("parseChunkSize", () => {
116
+ it("parses a valid chunk size", () => {
117
+ expect(parseChunkSize("20")).toBe(20);
118
+ });
119
+
120
+ it("accepts zero", () => {
121
+ expect(parseChunkSize("0")).toBe(0);
122
+ });
123
+
124
+ it("throws on negative value", () => {
125
+ expect(() => parseChunkSize("-5")).toThrow('Invalid chunk size "-5"');
126
+ });
127
+
128
+ it("throws on non-numeric value", () => {
129
+ expect(() => parseChunkSize("abc")).toThrow('Invalid chunk size "abc"');
130
+ });
131
+ });