From fbe1457ab63202503b669c4ad4022593750394da Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 21:04:55 +0000 Subject: [PATCH] Add protocol codec tests and vitest setup --- package.json | 22 ++++ tests/codec.test.ts | 237 ++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 17 ++++ vitest.config.ts | 8 ++ 4 files changed, 284 insertions(+) create mode 100644 package.json create mode 100644 tests/codec.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/package.json b/package.json new file mode 100644 index 0000000..cd43c43 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "yonexus-protocol", + "version": "0.1.0", + "description": "Yonexus shared communication protocol", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "typescript": "^5.0.0", + "vitest": "^1.0.0" + }, + "files": [ + "dist/", + "src/", + "PROTOCOL.md" + ] +} diff --git a/tests/codec.test.ts b/tests/codec.test.ts new file mode 100644 index 0000000..29cf1fe --- /dev/null +++ b/tests/codec.test.ts @@ -0,0 +1,237 @@ +: +import { describe, it, expect, beforeEach } from "vitest"; +import { + encodeBuiltin, + decodeBuiltin, + parseRuleMessage, + encodeRuleMessage, + parseRewrittenRuleMessage, + encodeRewrittenRuleMessage, + isBuiltinMessage, + CodecError +} from "../src/codec.js"; +import type { BuiltinEnvelope, HelloPayload, HelloAckPayload } from "../src/types.js"; + +describe("Protocol Codec", () => { + describe("encodeBuiltin / decodeBuiltin", () => { + it("should encode and decode a hello message", () => { + const envelope: BuiltinEnvelope<"hello", HelloPayload> = { + type: "hello", + requestId: "req_001", + timestamp: 1711886400, + payload: { + identifier: "client-a", + hasSecret: true, + hasKeyPair: true, + publicKey: "pk_test", + protocolVersion: "1" + } + }; + + const encoded = encodeBuiltin(envelope); + expect(encoded).toBe( + 'builtin::{"type":"hello","requestId":"req_001","timestamp":1711886400,"payload":{"identifier":"client-a","hasSecret":true,"hasKeyPair":true,"publicKey":"pk_test","protocolVersion":"1"}}' + ); + + const decoded = decodeBuiltin(encoded); + expect(decoded).toEqual(envelope); + }); + + it("should encode and decode a hello_ack message", () => { + const envelope: BuiltinEnvelope<"hello_ack", HelloAckPayload> = { + type: "hello_ack", + requestId: "req_001", + timestamp: 1711886401, + payload: { + identifier: "client-a", + nextAction: "pair_required" + } + }; + + const encoded = encodeBuiltin(envelope); + const decoded = decodeBuiltin(encoded); + expect(decoded).toEqual(envelope); + }); + + it("should handle envelope without optional fields", () => { + const envelope: BuiltinEnvelope = { + type: "heartbeat" + }; + + const encoded = encodeBuiltin(envelope); + expect(encoded).toBe('builtin::{"type":"heartbeat"}'); + + const decoded = decodeBuiltin(encoded); + expect(decoded).toEqual(envelope); + }); + + it("should throw CodecError for malformed builtin message", () => { + expect(() => decodeBuiltin("invalid")).toThrow(CodecError); + expect(() => decodeBuiltin("builtin::")).toThrow(CodecError); + expect(() => decodeBuiltin("builtin::invalid json")).toThrow(CodecError); + }); + + it("should throw CodecError for non-builtin message prefix", () => { + expect(() => decodeBuiltin("hello::{\"type\":\"test\"}")).toThrow(CodecError); + }); + }); + + describe("isBuiltinMessage", () => { + it("should return true for builtin messages", () => { + expect(isBuiltinMessage('builtin::{"type":"hello"}')).toBe(true); + expect(isBuiltinMessage("builtin::{}")).toBe(true); + }); + + it("should return false for non-builtin messages", () => { + expect(isBuiltinMessage("hello::world")).toBe(false); + expect(isBuiltinMessage("rule::content")).toBe(false); + expect(isBuiltinMessage("")).toBe(false); + }); + }); + + describe("parseRuleMessage", () => { + it("should parse simple rule message", () => { + const result = parseRuleMessage("chat_sync::{\"body\":\"hello\"}"); + expect(result).toEqual({ + rule: "chat_sync", + content: '{"body":"hello"}' + }); + }); + + it("should parse rule message with content containing ::", () => { + const result = parseRuleMessage("chat_sync::sender::extra::{\"body\":\"hello::world\"}"); + expect(result).toEqual({ + rule: "chat_sync", + content: 'sender::extra::{"body":"hello::world"}' + }); + }); + + it("should throw CodecError for malformed rule message", () => { + expect(() => parseRuleMessage("invalid")).toThrow(CodecError); + expect(() => parseRuleMessage("")).toThrow(CodecError); + }); + + it("should reject builtin as rule identifier", () => { + expect(() => parseRuleMessage('builtin::{"type":"hello"}')).toThrow(CodecError); + }); + }); + + describe("encodeRuleMessage", () => { + it("should encode rule message", () => { + const encoded = encodeRuleMessage("chat_sync", '{"body":"hello"}'); + expect(encoded).toBe('chat_sync::{"body":"hello"}'); + }); + + it("should reject builtin as rule", () => { + expect(() => encodeRuleMessage("builtin", "content")).toThrow(CodecError); + }); + }); + + describe("parseRewrittenRuleMessage", () => { + it("should parse rewritten server-side message", () => { + const result = parseRewrittenRuleMessage("chat_sync::client-a::{\"body\":\"hello\"}"); + expect(result).toEqual({ + rule: "chat_sync", + sender: "client-a", + content: '{"body":"hello"}' + }); + }); + + it("should handle content with :: delimiters", () => { + const result = parseRewrittenRuleMessage("chat_sync::client-a::extra::{\"body\":\"hello::world\"}"); + expect(result).toEqual({ + rule: "chat_sync", + sender: "client-a", + content: 'extra::{"body":"hello::world"}' + }); + }); + + it("should throw CodecError for malformed rewritten message", () => { + expect(() => parseRewrittenRuleMessage("rule::content")).toThrow(CodecError); + expect(() => parseRewrittenRuleMessage("invalid")).toThrow(CodecError); + }); + }); + + describe("encodeRewrittenRuleMessage", () => { + it("should encode rewritten message", () => { + const encoded = encodeRewrittenRuleMessage("chat_sync", "client-a", '{"body":"hello"}'); + expect(encoded).toBe('chat_sync::client-a::{"body":"hello"}'); + }); + + it("should reject builtin as rule", () => { + expect(() => encodeRewrittenRuleMessage("builtin", "sender", "content")).toThrow(CodecError); + }); + }); +}); + +describe("Protocol Message Examples", () => { + describe("Hello Flow", () => { + it("should demonstrate complete hello flow", () => { + // Client sends hello + const hello: BuiltinEnvelope<"hello", HelloPayload> = { + type: "hello", + requestId: "req_001", + timestamp: Math.floor(Date.now() / 1000), + payload: { + identifier: "client-a", + hasSecret: false, + hasKeyPair: true, + publicKey: "ed25519_pk_test123", + protocolVersion: "1" + } + }; + + const helloWire = encodeBuiltin(hello); + expect(isBuiltinMessage(helloWire)).toBe(true); + + // Server parses hello + const parsedHello = decodeBuiltin(helloWire); + expect(parsedHello.type).toBe("hello"); + expect((parsedHello.payload as HelloPayload).identifier).toBe("client-a"); + + // Server responds with hello_ack + const helloAck: BuiltinEnvelope<"hello_ack", HelloAckPayload> = { + type: "hello_ack", + requestId: hello.requestId, + timestamp: Math.floor(Date.now() / 1000), + payload: { + identifier: "client-a", + nextAction: "pair_required" + } + }; + + const ackWire = encodeBuiltin(helloAck); + const parsedAck = decodeBuiltin(ackWire); + expect(parsedAck.type).toBe("hello_ack"); + expect((parsedAck.payload as HelloAckPayload).nextAction).toBe("pair_required"); + }); + }); + + describe("Rule Message Flow", () => { + it("should demonstrate client to server message", () => { + const message = encodeRuleMessage("chat_sync", '{"conversationId":"abc","body":"hello"}'); + expect(message).toBe('chat_sync::{"conversationId":"abc","body":"hello"}'); + + // Server parses + const parsed = parseRuleMessage(message); + expect(parsed.rule).toBe("chat_sync"); + expect(parsed.content).toBe('{"conversationId":"abc","body":"hello"}'); + }); + + it("should demonstrate server-side rewriting", () => { + // Server rewrites incoming message + const rewritten = encodeRewrittenRuleMessage( + "chat_sync", + "client-a", + '{"conversationId":"abc","body":"hello"}' + ); + expect(rewritten).toBe('chat_sync::client-a::{"conversationId":"abc","body":"hello"}'); + + // Server-side handler parses + const parsed = parseRewrittenRuleMessage(rewritten); + expect(parsed.rule).toBe("chat_sync"); + expect(parsed.sender).toBe("client-a"); + expect(parsed.content).toBe('{"conversationId":"abc","body":"hello"}'); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f04e124 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7fed22e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node" + } +});