| ... | @@ -1,8 +1,28 @@ | ... | @@ -1,8 +1,28 @@ |
| 1 | import { CommandId, ORCHESTRATION_WS_METHODS, ProjectId, ThreadId } from "@t3tools/contracts"; | 1 | import { |
| 2 | import { describe, expect, it, vi } from "vitest"; | 2 | CommandId, |
| | 3 | ORCHESTRATION_WS_CHANNELS, |
| | 4 | ORCHESTRATION_WS_METHODS, |
| | 5 | ProjectId, |
| | 6 | ThreadId, |
| | 7 | WS_CHANNELS, |
| | 8 | } from "@t3tools/contracts"; |
| | 9 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | | 10 | |
| 4 | const requestMock = vi.fn<(...args: Array<unknown>) => Promise<unknown>>(); | 11 | const requestMock = vi.fn<(...args: Array<unknown>) => Promise<unknown>>(); |
| 5 | const subscribeMock = vi.fn<(...args: Array<unknown>) => () => void>(() => () => undefined); | 12 | const channelListeners = new Map<string, Set<(data: unknown) => void>>(); |
| | 13 | const subscribeMock = vi.fn<(channel: string, listener: (data: unknown) => void) => () => void>( |
| | 14 | (channel, listener) => { |
| | 15 | const listeners = channelListeners.get(channel) ?? new Set<(data: unknown) => void>(); |
| | 16 | listeners.add(listener); |
| | 17 | channelListeners.set(channel, listeners); |
| | 18 | return () => { |
| | 19 | listeners.delete(listener); |
| | 20 | if (listeners.size === 0) { |
| | 21 | channelListeners.delete(channel); |
| | 22 | } |
| | 23 | }; |
| | 24 | }, |
| | 25 | ); |
| 6 | | 26 | |
| 7 | vi.mock("./wsTransport", () => { | 27 | vi.mock("./wsTransport", () => { |
| 8 | return { | 28 | return { |
| ... | @@ -13,7 +33,166 @@ vi.mock("./wsTransport", () => { | ... | @@ -13,7 +33,166 @@ vi.mock("./wsTransport", () => { |
| 13 | }; | 33 | }; |
| 14 | }); | 34 | }); |
| 15 | | 35 | |
| | 36 | function emitPush(channel: string, data: unknown): void { |
| | 37 | const listeners = channelListeners.get(channel); |
| | 38 | if (!listeners) return; |
| | 39 | for (const listener of listeners) { |
| | 40 | listener(data); |
| | 41 | } |
| | 42 | } |
| | 43 | |
| | 44 | beforeEach(() => { |
| | 45 | vi.resetModules(); |
| | 46 | requestMock.mockReset(); |
| | 47 | subscribeMock.mockClear(); |
| | 48 | channelListeners.clear(); |
| | 49 | }); |
| | 50 | |
| | 51 | afterEach(() => { |
| | 52 | vi.restoreAllMocks(); |
| | 53 | }); |
| | 54 | |
| 16 | describe("wsNativeApi", () => { | 55 | describe("wsNativeApi", () => { |
| | 56 | it("delivers and caches valid server.welcome payloads", async () => { |
| | 57 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); |
| | 58 | const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); |
| | 59 | |
| | 60 | createWsNativeApi(); |
| | 61 | const listener = vi.fn(); |
| | 62 | onServerWelcome(listener); |
| | 63 | |
| | 64 | const payload = { cwd: "/tmp/workspace", projectName: "t3-code" }; |
| | 65 | emitPush(WS_CHANNELS.serverWelcome, payload); |
| | 66 | |
| | 67 | expect(listener).toHaveBeenCalledTimes(1); |
| | 68 | expect(listener).toHaveBeenCalledWith(payload); |
| | 69 | |
| | 70 | const lateListener = vi.fn(); |
| | 71 | onServerWelcome(lateListener); |
| | 72 | |
| | 73 | expect(lateListener).toHaveBeenCalledTimes(1); |
| | 74 | expect(lateListener).toHaveBeenCalledWith(payload); |
| | 75 | expect(warnSpy).not.toHaveBeenCalled(); |
| | 76 | }); |
| | 77 | |
| | 78 | it("ignores invalid server.welcome payloads and keeps subscription active", async () => { |
| | 79 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); |
| | 80 | const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); |
| | 81 | |
| | 82 | createWsNativeApi(); |
| | 83 | const listener = vi.fn(); |
| | 84 | onServerWelcome(listener); |
| | 85 | |
| | 86 | emitPush(WS_CHANNELS.serverWelcome, { cwd: 42, projectName: "t3-code" }); |
| | 87 | emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" }); |
| | 88 | |
| | 89 | expect(listener).toHaveBeenCalledTimes(1); |
| | 90 | expect(listener).toHaveBeenCalledWith({ cwd: "/tmp/workspace", projectName: "t3-code" }); |
| | 91 | expect(warnSpy).toHaveBeenCalledTimes(1); |
| | 92 | expect(warnSpy).toHaveBeenCalledWith("Dropped inbound WebSocket push payload", { |
| | 93 | reason: "decode-failed", |
| | 94 | raw: { cwd: 42, projectName: "t3-code" }, |
| | 95 | issue: expect.stringContaining("SchemaError"), |
| | 96 | }); |
| | 97 | }); |
| | 98 | |
| | 99 | it("forwards valid terminal and orchestration events", async () => { |
| | 100 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); |
| | 101 | const { createWsNativeApi } = await import("./wsNativeApi"); |
| | 102 | |
| | 103 | const api = createWsNativeApi(); |
| | 104 | const onTerminalEvent = vi.fn(); |
| | 105 | const onDomainEvent = vi.fn(); |
| | 106 | |
| | 107 | api.terminal.onEvent(onTerminalEvent); |
| | 108 | api.orchestration.onDomainEvent(onDomainEvent); |
| | 109 | |
| | 110 | const terminalEvent = { |
| | 111 | threadId: "thread-1", |
| | 112 | terminalId: "terminal-1", |
| | 113 | createdAt: "2026-02-24T00:00:00.000Z", |
| | 114 | type: "output", |
| | 115 | data: "hello", |
| | 116 | } as const; |
| | 117 | emitPush(WS_CHANNELS.terminalEvent, terminalEvent); |
| | 118 | |
| | 119 | const orchestrationEvent = { |
| | 120 | sequence: 1, |
| | 121 | eventId: "event-1", |
| | 122 | aggregateKind: "project", |
| | 123 | aggregateId: "project-1", |
| | 124 | occurredAt: "2026-02-24T00:00:00.000Z", |
| | 125 | commandId: null, |
| | 126 | causationEventId: null, |
| | 127 | correlationId: null, |
| | 128 | metadata: {}, |
| | 129 | type: "project.created", |
| | 130 | payload: { |
| | 131 | projectId: "project-1", |
| | 132 | title: "Project", |
| | 133 | workspaceRoot: "/tmp/workspace", |
| | 134 | defaultModel: null, |
| | 135 | scripts: [], |
| | 136 | createdAt: "2026-02-24T00:00:00.000Z", |
| | 137 | updatedAt: "2026-02-24T00:00:00.000Z", |
| | 138 | }, |
| | 139 | } as const; |
| | 140 | emitPush(ORCHESTRATION_WS_CHANNELS.domainEvent, orchestrationEvent); |
| | 141 | |
| | 142 | expect(onTerminalEvent).toHaveBeenCalledTimes(1); |
| | 143 | expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); |
| | 144 | expect(onDomainEvent).toHaveBeenCalledTimes(1); |
| | 145 | expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); |
| | 146 | expect(warnSpy).not.toHaveBeenCalled(); |
| | 147 | }); |
| | 148 | |
| | 149 | it("drops malformed terminal and orchestration push payloads", async () => { |
| | 150 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); |
| | 151 | const { createWsNativeApi } = await import("./wsNativeApi"); |
| | 152 | |
| | 153 | const api = createWsNativeApi(); |
| | 154 | const onTerminalEvent = vi.fn(); |
| | 155 | const onDomainEvent = vi.fn(); |
| | 156 | |
| | 157 | api.terminal.onEvent(onTerminalEvent); |
| | 158 | api.orchestration.onDomainEvent(onDomainEvent); |
| | 159 | |
| | 160 | emitPush(WS_CHANNELS.terminalEvent, { |
| | 161 | threadId: "thread-1", |
| | 162 | terminalId: "", |
| | 163 | createdAt: "2026-02-24T00:00:00.000Z", |
| | 164 | type: "output", |
| | 165 | data: "hello", |
| | 166 | }); |
| | 167 | emitPush(ORCHESTRATION_WS_CHANNELS.domainEvent, { |
| | 168 | sequence: -1, |
| | 169 | type: "project.created", |
| | 170 | }); |
| | 171 | |
| | 172 | expect(onTerminalEvent).not.toHaveBeenCalled(); |
| | 173 | expect(onDomainEvent).not.toHaveBeenCalled(); |
| | 174 | expect(warnSpy).toHaveBeenCalledTimes(2); |
| | 175 | expect(warnSpy).toHaveBeenNthCalledWith(1, "Dropped inbound WebSocket push payload", { |
| | 176 | reason: "decode-failed", |
| | 177 | raw: { |
| | 178 | threadId: "thread-1", |
| | 179 | terminalId: "", |
| | 180 | createdAt: "2026-02-24T00:00:00.000Z", |
| | 181 | type: "output", |
| | 182 | data: "hello", |
| | 183 | }, |
| | 184 | issue: expect.stringContaining("SchemaError"), |
| | 185 | }); |
| | 186 | expect(warnSpy).toHaveBeenNthCalledWith(2, "Dropped inbound WebSocket push payload", { |
| | 187 | reason: "decode-failed", |
| | 188 | raw: { |
| | 189 | sequence: -1, |
| | 190 | type: "project.created", |
| | 191 | }, |
| | 192 | issue: expect.stringContaining("SchemaError"), |
| | 193 | }); |
| | 194 | }); |
| | 195 | |
| 17 | it("wraps orchestration dispatch commands in the command envelope", async () => { | 196 | it("wraps orchestration dispatch commands in the command envelope", async () => { |
| 18 | requestMock.mockResolvedValue(undefined); | 197 | requestMock.mockResolvedValue(undefined); |
| 19 | const { createWsNativeApi } = await import("./wsNativeApi"); | 198 | const { createWsNativeApi } = await import("./wsNativeApi"); |