@mandujs/cli 0.15.1 → 0.15.3
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/README.ko.md +33 -33
- package/README.md +354 -354
- package/package.json +2 -2
- package/src/commands/check.ts +71 -7
- package/src/commands/contract.ts +173 -173
- package/src/commands/dev.ts +9 -42
- package/src/commands/guard-arch.ts +303 -303
- package/src/commands/init.ts +50 -5
- package/src/commands/monitor.ts +300 -300
- package/src/commands/openapi.ts +107 -107
- package/src/commands/registry.ts +1 -0
- package/src/commands/start.ts +9 -42
- package/src/errors/codes.ts +35 -35
- package/src/errors/index.ts +2 -2
- package/src/errors/messages.ts +143 -143
- package/src/hooks/index.ts +17 -17
- package/src/hooks/preaction.ts +256 -256
- package/src/main.ts +9 -7
- package/src/terminal/banner.ts +166 -166
- package/src/terminal/help.ts +306 -306
- package/src/terminal/index.ts +71 -71
- package/src/terminal/output.ts +295 -295
- package/src/terminal/palette.ts +30 -30
- package/src/terminal/progress.ts +327 -327
- package/src/terminal/stream-writer.ts +214 -214
- package/src/terminal/table.ts +354 -354
- package/src/terminal/theme.ts +142 -142
- package/src/util/bun.ts +6 -6
- package/src/util/fs.ts +23 -23
- package/src/util/handlers.ts +49 -5
- package/src/util/lockfile.ts +66 -0
- package/src/util/output.ts +22 -22
- package/src/util/port.ts +71 -71
- package/templates/default/AGENTS.md +96 -96
- package/templates/default/app/api/health/route.ts +13 -13
- package/templates/default/app/globals.css +49 -49
- package/templates/default/app/layout.tsx +27 -27
- package/templates/default/app/page.tsx +38 -38
- package/templates/default/src/client/shared/lib/utils.ts +16 -16
- package/templates/default/src/client/shared/ui/button.tsx +57 -57
- package/templates/default/src/client/shared/ui/card.tsx +1 -1
- package/templates/default/src/client/shared/ui/index.ts +21 -21
- package/templates/default/src/client/shared/ui/input.tsx +5 -1
- package/templates/default/tests/example.test.ts +58 -58
- package/templates/default/tests/helpers.ts +52 -52
- package/templates/default/tests/setup.ts +9 -9
- package/templates/default/tsconfig.json +23 -23
- package/templates/realtime-chat/AGENTS.md +96 -0
- package/templates/realtime-chat/app/api/chat/messages/route.ts +63 -0
- package/templates/realtime-chat/app/api/chat/stream/route.ts +85 -0
- package/templates/realtime-chat/app/api/health/route.ts +13 -0
- package/templates/realtime-chat/app/globals.css +49 -0
- package/templates/realtime-chat/app/layout.tsx +27 -0
- package/templates/realtime-chat/app/page.tsx +16 -0
- package/templates/realtime-chat/package.json +34 -0
- package/templates/realtime-chat/src/client/app/index.ts +1 -0
- package/templates/realtime-chat/src/client/entities/index.ts +1 -0
- package/templates/realtime-chat/src/client/features/chat/chat-api.ts +209 -0
- package/templates/realtime-chat/src/client/features/chat/realtime-chat-starter.client.tsx +89 -0
- package/templates/realtime-chat/src/client/features/chat/use-realtime-chat.ts +65 -0
- package/templates/realtime-chat/src/client/features/index.ts +1 -0
- package/templates/realtime-chat/src/client/pages/index.ts +1 -0
- package/templates/realtime-chat/src/client/shared/index.ts +1 -0
- package/templates/realtime-chat/src/client/shared/lib/utils.ts +16 -0
- package/templates/realtime-chat/src/client/shared/ui/button.tsx +57 -0
- package/templates/realtime-chat/src/client/shared/ui/card.tsx +78 -0
- package/templates/realtime-chat/src/client/shared/ui/index.ts +21 -0
- package/templates/realtime-chat/src/client/shared/ui/input.tsx +28 -0
- package/templates/realtime-chat/src/client/widgets/index.ts +1 -0
- package/templates/realtime-chat/src/server/api/index.ts +1 -0
- package/templates/realtime-chat/src/server/application/ai-adapter.ts +24 -0
- package/templates/realtime-chat/src/server/application/chat-store.ts +158 -0
- package/templates/realtime-chat/src/server/application/index.ts +1 -0
- package/templates/realtime-chat/src/server/core/index.ts +1 -0
- package/templates/realtime-chat/src/server/domain/index.ts +1 -0
- package/templates/realtime-chat/src/server/infra/index.ts +1 -0
- package/templates/realtime-chat/src/shared/contracts/chat.ts +29 -0
- package/templates/realtime-chat/src/shared/contracts/index.ts +1 -0
- package/templates/realtime-chat/src/shared/env/index.ts +1 -0
- package/templates/realtime-chat/src/shared/schema/index.ts +1 -0
- package/templates/realtime-chat/src/shared/types/index.ts +1 -0
- package/templates/realtime-chat/src/shared/utils/client/index.ts +1 -0
- package/templates/realtime-chat/src/shared/utils/server/index.ts +1 -0
- package/templates/realtime-chat/tests/chat-api.sse.test.ts +188 -0
- package/templates/realtime-chat/tests/chat-starter.test.ts +200 -0
- package/templates/realtime-chat/tests/chat-store.concurrency.test.ts +39 -0
- package/templates/realtime-chat/tests/example.test.ts +58 -0
- package/templates/realtime-chat/tests/helpers.ts +52 -0
- package/templates/realtime-chat/tests/setup.ts +9 -0
- package/templates/realtime-chat/tsconfig.json +23 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { ChatMessage } from "@/shared/contracts/chat";
|
|
2
|
+
|
|
3
|
+
type ChatListener = (event: ChatMessageEvent) => void;
|
|
4
|
+
|
|
5
|
+
export interface ChatMessageEvent {
|
|
6
|
+
eventId: string;
|
|
7
|
+
message: ChatMessage;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type SubscriptionSnapshot = {
|
|
11
|
+
snapshot: ChatMessage[];
|
|
12
|
+
commit: () => () => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface ResumePlan {
|
|
16
|
+
mode: "catch-up" | "snapshot";
|
|
17
|
+
events: ChatMessageEvent[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const listeners = new Set<ChatListener>();
|
|
21
|
+
const messages: ChatMessage[] = [];
|
|
22
|
+
const MAX_HISTORY_MESSAGES = 200;
|
|
23
|
+
const MAX_CATCH_UP_EVENTS = 500;
|
|
24
|
+
let storeVersion = 0;
|
|
25
|
+
let streamEventSeq = 0;
|
|
26
|
+
const catchUpEvents: ChatMessageEvent[] = [];
|
|
27
|
+
let testHookBeforeSubscribeCommit: (() => void) | undefined;
|
|
28
|
+
|
|
29
|
+
function createMessage(role: ChatMessage["role"], text: string): ChatMessage {
|
|
30
|
+
return {
|
|
31
|
+
id: crypto.randomUUID(),
|
|
32
|
+
role,
|
|
33
|
+
text,
|
|
34
|
+
createdAt: new Date().toISOString(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createEventId(nextSeq: number): string {
|
|
39
|
+
return `msg-${nextSeq}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseEventSeq(eventId: string | null | undefined): number | null {
|
|
43
|
+
if (!eventId) return null;
|
|
44
|
+
const match = /^msg-(\d+)$/.exec(eventId);
|
|
45
|
+
if (!match) return null;
|
|
46
|
+
const parsed = Number.parseInt(match[1]!, 10);
|
|
47
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pushCatchUpEvent(event: ChatMessageEvent): void {
|
|
51
|
+
catchUpEvents.push(event);
|
|
52
|
+
if (catchUpEvents.length > MAX_CATCH_UP_EVENTS) {
|
|
53
|
+
catchUpEvents.splice(0, catchUpEvents.length - MAX_CATCH_UP_EVENTS);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getMessages(): ChatMessage[] {
|
|
58
|
+
return [...messages];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function appendMessage(role: ChatMessage["role"], text: string): ChatMessage {
|
|
62
|
+
const message = createMessage(role, text);
|
|
63
|
+
messages.push(message);
|
|
64
|
+
|
|
65
|
+
if (messages.length > MAX_HISTORY_MESSAGES) {
|
|
66
|
+
messages.splice(0, messages.length - MAX_HISTORY_MESSAGES);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
storeVersion += 1;
|
|
70
|
+
streamEventSeq += 1;
|
|
71
|
+
|
|
72
|
+
const event: ChatMessageEvent = {
|
|
73
|
+
eventId: createEventId(streamEventSeq),
|
|
74
|
+
message,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
pushCatchUpEvent(event);
|
|
78
|
+
|
|
79
|
+
for (const listener of listeners) {
|
|
80
|
+
try {
|
|
81
|
+
listener(event);
|
|
82
|
+
} catch {
|
|
83
|
+
// Ignore listener errors so one broken subscriber does not stop fan-out.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return message;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function subscribe(listener: ChatListener): () => void {
|
|
91
|
+
listeners.add(listener);
|
|
92
|
+
return () => listeners.delete(listener);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function subscribeWithSnapshot(listener: ChatListener): SubscriptionSnapshot {
|
|
96
|
+
// Optimistic lock-free retry: snapshot과 subscribe 사이에 write가 끼면 재시도
|
|
97
|
+
// => snapshot-subscription 경계에서 메시지 유실 방지
|
|
98
|
+
// commit()은 snapshot 전송 후 호출하여 listener 활성화 (이벤트 순서 보장)
|
|
99
|
+
for (;;) {
|
|
100
|
+
const beforeVersion = storeVersion;
|
|
101
|
+
const snapshot = [...messages];
|
|
102
|
+
|
|
103
|
+
testHookBeforeSubscribeCommit?.();
|
|
104
|
+
|
|
105
|
+
if (beforeVersion === storeVersion) {
|
|
106
|
+
return {
|
|
107
|
+
snapshot,
|
|
108
|
+
commit: () => {
|
|
109
|
+
listeners.add(listener);
|
|
110
|
+
return () => listeners.delete(listener);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function planResumeFrom(lastEventId: string | null | undefined): ResumePlan {
|
|
118
|
+
const parsedSeq = parseEventSeq(lastEventId);
|
|
119
|
+
if (parsedSeq === null) {
|
|
120
|
+
return { mode: "snapshot", events: [] };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (parsedSeq === streamEventSeq) {
|
|
124
|
+
return { mode: "catch-up", events: [] };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const firstAvailable = catchUpEvents[0];
|
|
128
|
+
if (!firstAvailable) {
|
|
129
|
+
return { mode: "snapshot", events: [] };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const firstSeq = parseEventSeq(firstAvailable.eventId);
|
|
133
|
+
if (firstSeq === null || parsedSeq < firstSeq - 1 || parsedSeq > streamEventSeq) {
|
|
134
|
+
return { mode: "snapshot", events: [] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const events = catchUpEvents.filter((event) => {
|
|
138
|
+
const seq = parseEventSeq(event.eventId);
|
|
139
|
+
return seq !== null && seq > parsedSeq;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return { mode: "catch-up", events };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function __resetChatStoreForTests(): void {
|
|
146
|
+
messages.length = 0;
|
|
147
|
+
listeners.clear();
|
|
148
|
+
storeVersion = 0;
|
|
149
|
+
streamEventSeq = 0;
|
|
150
|
+
catchUpEvents.length = 0;
|
|
151
|
+
testHookBeforeSubscribeCommit = undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function __setSubscribeCommitHookForTests(hook?: () => void): void {
|
|
155
|
+
testHookBeforeSubscribeCommit = hook;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export { MAX_HISTORY_MESSAGES, MAX_CATCH_UP_EVENTS };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface ChatMessage {
|
|
2
|
+
id: string;
|
|
3
|
+
role: "user" | "assistant";
|
|
4
|
+
text: string;
|
|
5
|
+
createdAt: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ChatMessagePayload {
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ChatMessageResponse {
|
|
13
|
+
message: ChatMessage;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ChatHistoryResponse {
|
|
17
|
+
messages: ChatMessage[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ChatStreamEvent {
|
|
21
|
+
type: "snapshot" | "message" | "heartbeat";
|
|
22
|
+
data: ChatMessage[] | ChatMessage | { ts: string };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isChatMessagePayload(value: unknown): value is ChatMessagePayload {
|
|
26
|
+
if (!value || typeof value !== "object") return false;
|
|
27
|
+
const candidate = value as Partial<ChatMessagePayload>;
|
|
28
|
+
return typeof candidate.text === "string" && candidate.text.trim().length > 0;
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./chat";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { mergeChatMessages, openChatStream } from "@/client/features/chat/chat-api";
|
|
3
|
+
import type { ChatStreamEvent } from "@/shared/contracts/chat";
|
|
4
|
+
|
|
5
|
+
type MessageEventLike = { data: string; lastEventId?: string };
|
|
6
|
+
|
|
7
|
+
class FakeEventSource {
|
|
8
|
+
onopen: ((event: Event) => void) | null = null;
|
|
9
|
+
onmessage: ((event: MessageEventLike) => void) | null = null;
|
|
10
|
+
onerror: ((event: Event) => void) | null = null;
|
|
11
|
+
closed = false;
|
|
12
|
+
|
|
13
|
+
constructor(readonly url: string) {}
|
|
14
|
+
|
|
15
|
+
emitOpen() {
|
|
16
|
+
this.onopen?.(new Event("open"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
emitMessage(data: string, lastEventId?: string) {
|
|
20
|
+
this.onmessage?.({ data, lastEventId });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
emitError() {
|
|
24
|
+
this.onerror?.(new Event("error"));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
close() {
|
|
28
|
+
this.closed = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sleep(ms: number): Promise<void> {
|
|
33
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("openChatStream", () => {
|
|
37
|
+
it("reconnects with capped retries when stream errors", async () => {
|
|
38
|
+
const sources: FakeEventSource[] = [];
|
|
39
|
+
const stop = openChatStream(() => {}, {
|
|
40
|
+
baseDelayMs: 5,
|
|
41
|
+
maxDelayMs: 20,
|
|
42
|
+
jitterRatio: 0,
|
|
43
|
+
maxRetries: 2,
|
|
44
|
+
eventSourceFactory: (url) => {
|
|
45
|
+
const source = new FakeEventSource(url);
|
|
46
|
+
sources.push(source);
|
|
47
|
+
return source as unknown as EventSource;
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(sources.length).toBe(1);
|
|
52
|
+
expect(sources[0]?.url).toBe("/api/chat/stream");
|
|
53
|
+
|
|
54
|
+
sources[0]?.emitError();
|
|
55
|
+
await sleep(8);
|
|
56
|
+
expect(sources.length).toBe(2);
|
|
57
|
+
|
|
58
|
+
sources[1]?.emitError();
|
|
59
|
+
await sleep(12);
|
|
60
|
+
expect(sources.length).toBe(3);
|
|
61
|
+
|
|
62
|
+
sources[2]?.emitError();
|
|
63
|
+
await sleep(25);
|
|
64
|
+
expect(sources.length).toBe(3);
|
|
65
|
+
|
|
66
|
+
stop();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("adds lastEventId cursor on reconnect for resumable SSE", async () => {
|
|
70
|
+
const sources: FakeEventSource[] = [];
|
|
71
|
+
|
|
72
|
+
const stop = openChatStream(() => {}, {
|
|
73
|
+
baseDelayMs: 5,
|
|
74
|
+
maxDelayMs: 5,
|
|
75
|
+
jitterRatio: 0,
|
|
76
|
+
maxRetries: 1,
|
|
77
|
+
eventSourceFactory: (url) => {
|
|
78
|
+
const source = new FakeEventSource(url);
|
|
79
|
+
sources.push(source);
|
|
80
|
+
return source as unknown as EventSource;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(sources[0]?.url).toBe("/api/chat/stream");
|
|
85
|
+
|
|
86
|
+
sources[0]?.emitMessage(JSON.stringify({ type: "message" }), "msg-42");
|
|
87
|
+
sources[0]?.emitError();
|
|
88
|
+
|
|
89
|
+
await sleep(8);
|
|
90
|
+
expect(sources[1]?.url).toBe("/api/chat/stream?lastEventId=msg-42");
|
|
91
|
+
|
|
92
|
+
stop();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("notifies connection state changes and terminal failure", async () => {
|
|
96
|
+
const states: Array<"connecting" | "connected" | "reconnecting" | "failed" | "closed"> = [];
|
|
97
|
+
const sources: FakeEventSource[] = [];
|
|
98
|
+
|
|
99
|
+
const stop = openChatStream(() => {}, {
|
|
100
|
+
baseDelayMs: 5,
|
|
101
|
+
maxDelayMs: 5,
|
|
102
|
+
jitterRatio: 0,
|
|
103
|
+
maxRetries: 1,
|
|
104
|
+
onConnectionStateChange: (state) => {
|
|
105
|
+
states.push(state);
|
|
106
|
+
},
|
|
107
|
+
eventSourceFactory: (url) => {
|
|
108
|
+
const source = new FakeEventSource(url);
|
|
109
|
+
sources.push(source);
|
|
110
|
+
return source as unknown as EventSource;
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(states).toEqual(["connecting"]);
|
|
115
|
+
|
|
116
|
+
sources[0]?.emitOpen();
|
|
117
|
+
expect(states).toEqual(["connecting", "connected"]);
|
|
118
|
+
|
|
119
|
+
sources[0]?.emitError();
|
|
120
|
+
expect(states).toEqual(["connecting", "connected", "reconnecting"]);
|
|
121
|
+
|
|
122
|
+
await sleep(8);
|
|
123
|
+
expect(states).toEqual(["connecting", "connected", "reconnecting", "connecting"]);
|
|
124
|
+
|
|
125
|
+
sources[1]?.emitError();
|
|
126
|
+
expect(states).toEqual(["connecting", "connected", "reconnecting", "connecting", "failed"]);
|
|
127
|
+
|
|
128
|
+
stop();
|
|
129
|
+
expect(states).toEqual(["connecting", "connected", "reconnecting", "connecting", "failed", "closed"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("cleans up source and pending reconnect timer on stop", async () => {
|
|
133
|
+
const sources: FakeEventSource[] = [];
|
|
134
|
+
const stop = openChatStream(() => {}, {
|
|
135
|
+
baseDelayMs: 20,
|
|
136
|
+
maxDelayMs: 20,
|
|
137
|
+
jitterRatio: 0,
|
|
138
|
+
maxRetries: 3,
|
|
139
|
+
eventSourceFactory: (url) => {
|
|
140
|
+
const source = new FakeEventSource(url);
|
|
141
|
+
sources.push(source);
|
|
142
|
+
return source as unknown as EventSource;
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(sources.length).toBe(1);
|
|
147
|
+
|
|
148
|
+
sources[0]?.emitError();
|
|
149
|
+
stop();
|
|
150
|
+
|
|
151
|
+
await sleep(30);
|
|
152
|
+
expect(sources.length).toBe(1);
|
|
153
|
+
expect(sources[0]?.closed).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("forwards valid payload and ignores malformed payload", () => {
|
|
157
|
+
const events: ChatStreamEvent[] = [];
|
|
158
|
+
const sources: FakeEventSource[] = [];
|
|
159
|
+
|
|
160
|
+
const stop = openChatStream((event) => {
|
|
161
|
+
events.push(event);
|
|
162
|
+
}, {
|
|
163
|
+
eventSourceFactory: (url) => {
|
|
164
|
+
const source = new FakeEventSource(url);
|
|
165
|
+
sources.push(source);
|
|
166
|
+
return source as unknown as EventSource;
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
sources[0]?.emitMessage("{not-json");
|
|
171
|
+
sources[0]?.emitMessage(JSON.stringify({ type: "message" }));
|
|
172
|
+
|
|
173
|
+
expect(events).toEqual([{ type: "message" }]);
|
|
174
|
+
|
|
175
|
+
stop();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("mergeChatMessages", () => {
|
|
180
|
+
it("idempotently merges duplicate messages by id", () => {
|
|
181
|
+
const a = { id: "1", role: "user", text: "hello", createdAt: "2026-02-13T00:00:00.000Z" } as const;
|
|
182
|
+
const b = { id: "2", role: "assistant", text: "world", createdAt: "2026-02-13T00:00:01.000Z" } as const;
|
|
183
|
+
|
|
184
|
+
const merged = mergeChatMessages([a, b], [b]);
|
|
185
|
+
expect(merged).toHaveLength(2);
|
|
186
|
+
expect(merged.map((m) => m.id)).toEqual(["1", "2"]);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { GET as getMessages, POST as postMessage } from "../app/api/chat/messages/route";
|
|
3
|
+
import { GET as getStream } from "../app/api/chat/stream/route";
|
|
4
|
+
import {
|
|
5
|
+
__resetChatStoreForTests,
|
|
6
|
+
appendMessage,
|
|
7
|
+
getMessages as getStoreMessages,
|
|
8
|
+
MAX_HISTORY_MESSAGES,
|
|
9
|
+
} from "../src/server/application/chat-store";
|
|
10
|
+
import { getAIAdapter, setAIAdapter } from "../src/server/application/ai-adapter";
|
|
11
|
+
import { createTestRequest, parseJsonResponse } from "./helpers";
|
|
12
|
+
|
|
13
|
+
describe("realtime chat starter template", () => {
|
|
14
|
+
const originalAdapter = getAIAdapter();
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
__resetChatStoreForTests();
|
|
18
|
+
setAIAdapter(originalAdapter);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns empty history by default", async () => {
|
|
22
|
+
const request = createTestRequest("http://localhost:3000/api/chat/messages");
|
|
23
|
+
const response = getMessages(request);
|
|
24
|
+
expect(response.status).toBe(200);
|
|
25
|
+
|
|
26
|
+
const json = await parseJsonResponse<{ messages: unknown[] }>(response);
|
|
27
|
+
expect(Array.isArray(json.messages)).toBe(true);
|
|
28
|
+
expect(json.messages.length).toBe(0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects invalid message payload", async () => {
|
|
32
|
+
const request = createTestRequest("http://localhost:3000/api/chat/messages", {
|
|
33
|
+
method: "POST",
|
|
34
|
+
body: { text: " " },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const response = await postMessage(request);
|
|
38
|
+
expect(response.status).toBe(400);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("rejects malformed json payload", async () => {
|
|
42
|
+
const request = new Request("http://localhost:3000/api/chat/messages", {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "Content-Type": "application/json" },
|
|
45
|
+
body: "{not-valid-json",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const response = await postMessage(request);
|
|
49
|
+
expect(response.status).toBe(400);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("accepts valid payload and appends assistant reply", async () => {
|
|
53
|
+
const request = createTestRequest("http://localhost:3000/api/chat/messages", {
|
|
54
|
+
method: "POST",
|
|
55
|
+
body: { text: "hello" },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const response = await postMessage(request);
|
|
59
|
+
expect(response.status).toBe(201);
|
|
60
|
+
|
|
61
|
+
const historyRequest = createTestRequest("http://localhost:3000/api/chat/messages");
|
|
62
|
+
const history = getMessages(historyRequest);
|
|
63
|
+
const json = await parseJsonResponse<{ messages: Array<{ role: string }> }>(history);
|
|
64
|
+
|
|
65
|
+
expect(json.messages.length).toBe(2);
|
|
66
|
+
expect(json.messages.at(-1)?.role).toBe("assistant");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("keeps user message when adapter completion fails", async () => {
|
|
70
|
+
setAIAdapter({
|
|
71
|
+
async complete() {
|
|
72
|
+
throw new Error("adapter-failure");
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const request = createTestRequest("http://localhost:3000/api/chat/messages", {
|
|
77
|
+
method: "POST",
|
|
78
|
+
body: { text: "hello" },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const response = await postMessage(request);
|
|
82
|
+
expect(response.status).toBe(201);
|
|
83
|
+
|
|
84
|
+
const messages = getStoreMessages();
|
|
85
|
+
expect(messages.length).toBe(1);
|
|
86
|
+
expect(messages[0]?.role).toBe("user");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("caps in-memory history size", () => {
|
|
90
|
+
for (let i = 0; i < MAX_HISTORY_MESSAGES + 25; i++) {
|
|
91
|
+
appendMessage("user", `msg-${i}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const messages = getStoreMessages();
|
|
95
|
+
expect(messages.length).toBe(MAX_HISTORY_MESSAGES);
|
|
96
|
+
expect(messages[0]?.text).toBe("msg-25");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("exposes SSE stream endpoint", () => {
|
|
100
|
+
const request = createTestRequest("http://localhost:3000/api/chat/stream");
|
|
101
|
+
const response = getStream(request);
|
|
102
|
+
expect(response.status).toBe(200);
|
|
103
|
+
expect(response.headers.get("Content-Type")).toContain("text/event-stream");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("SSE stream emits snapshot and live message events", async () => {
|
|
107
|
+
const abortController = new AbortController();
|
|
108
|
+
const request = new Request("http://localhost:3000/api/chat/stream", {
|
|
109
|
+
signal: abortController.signal,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const response = getStream(request);
|
|
113
|
+
const reader = response.body?.getReader();
|
|
114
|
+
expect(reader).toBeDefined();
|
|
115
|
+
|
|
116
|
+
const decoder = new TextDecoder();
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const firstChunk = await reader!.read();
|
|
120
|
+
expect(firstChunk.done).toBe(false);
|
|
121
|
+
const firstText = decoder.decode(firstChunk.value);
|
|
122
|
+
expect(firstText).toContain('"type":"snapshot"');
|
|
123
|
+
|
|
124
|
+
appendMessage("user", "live-event");
|
|
125
|
+
|
|
126
|
+
const secondChunk = await reader!.read();
|
|
127
|
+
expect(secondChunk.done).toBe(false);
|
|
128
|
+
const secondText = decoder.decode(secondChunk.value);
|
|
129
|
+
expect(secondText).toContain('"type":"message"');
|
|
130
|
+
expect(secondText).toContain('"text":"live-event"');
|
|
131
|
+
} finally {
|
|
132
|
+
abortController.abort();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("supports catch-up resume from lastEventId without snapshot", async () => {
|
|
137
|
+
appendMessage("user", "m1");
|
|
138
|
+
appendMessage("assistant", "m2");
|
|
139
|
+
|
|
140
|
+
const request = createTestRequest("http://localhost:3000/api/chat/stream?lastEventId=msg-1");
|
|
141
|
+
const response = getStream(request);
|
|
142
|
+
|
|
143
|
+
const reader = response.body?.getReader();
|
|
144
|
+
expect(reader).toBeDefined();
|
|
145
|
+
|
|
146
|
+
const firstChunk = await reader!.read();
|
|
147
|
+
expect(firstChunk.done).toBe(false);
|
|
148
|
+
|
|
149
|
+
const firstText = new TextDecoder().decode(firstChunk.value);
|
|
150
|
+
expect(firstText).toContain("id: msg-2");
|
|
151
|
+
expect(firstText).toContain('"type":"message"');
|
|
152
|
+
expect(firstText).toContain('"text":"m2"');
|
|
153
|
+
expect(firstText).not.toContain('"type":"snapshot"');
|
|
154
|
+
|
|
155
|
+
appendMessage("user", "m3-live");
|
|
156
|
+
|
|
157
|
+
const secondChunk = await reader!.read();
|
|
158
|
+
expect(secondChunk.done).toBe(false);
|
|
159
|
+
|
|
160
|
+
const secondText = new TextDecoder().decode(secondChunk.value);
|
|
161
|
+
expect(secondText).toContain("id: msg-3");
|
|
162
|
+
expect(secondText).toContain('"type":"message"');
|
|
163
|
+
expect(secondText).toContain('"text":"m3-live"');
|
|
164
|
+
|
|
165
|
+
await reader!.cancel();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("falls back to snapshot when lastEventId is out of catch-up range", async () => {
|
|
169
|
+
appendMessage("user", "hello");
|
|
170
|
+
|
|
171
|
+
const request = createTestRequest("http://localhost:3000/api/chat/stream?lastEventId=msg-9999");
|
|
172
|
+
const response = getStream(request);
|
|
173
|
+
|
|
174
|
+
const reader = response.body?.getReader();
|
|
175
|
+
expect(reader).toBeDefined();
|
|
176
|
+
|
|
177
|
+
const chunk = await reader!.read();
|
|
178
|
+
expect(chunk.done).toBe(false);
|
|
179
|
+
|
|
180
|
+
const text = new TextDecoder().decode(chunk.value);
|
|
181
|
+
expect(text).toContain('"type":"snapshot"');
|
|
182
|
+
expect(text).toContain('"text":"hello"');
|
|
183
|
+
|
|
184
|
+
await reader!.cancel();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("includes essential ARIA attributes in chat UI", async () => {
|
|
188
|
+
const source = await Bun.file(
|
|
189
|
+
new URL("../src/client/features/chat/realtime-chat-starter.client.tsx", import.meta.url),
|
|
190
|
+
).text();
|
|
191
|
+
|
|
192
|
+
expect(source).toContain('role="log"');
|
|
193
|
+
expect(source).toContain('aria-live="polite"');
|
|
194
|
+
expect(source).toContain('aria-label="Chat messages"');
|
|
195
|
+
expect(source).toContain('aria-label="Chat message input"');
|
|
196
|
+
expect(source).toContain('aria-describedby="chat-input-description"');
|
|
197
|
+
expect(source).toContain('id="chat-input-description"');
|
|
198
|
+
expect(source).toContain('aria-label="Send message"');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
__resetChatStoreForTests,
|
|
4
|
+
__setSubscribeCommitHookForTests,
|
|
5
|
+
appendMessage,
|
|
6
|
+
subscribeWithSnapshot,
|
|
7
|
+
} from "@/server/application/chat-store";
|
|
8
|
+
|
|
9
|
+
describe("chat-store concurrency", () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
__resetChatStoreForTests();
|
|
12
|
+
__setSubscribeCommitHookForTests(undefined);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("keeps snapshot/subscription consistent when write happens during subscribe", () => {
|
|
16
|
+
let hookTriggered = false;
|
|
17
|
+
|
|
18
|
+
__setSubscribeCommitHookForTests(() => {
|
|
19
|
+
if (hookTriggered) return;
|
|
20
|
+
hookTriggered = true;
|
|
21
|
+
appendMessage("user", "racing-message");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const seen: string[] = [];
|
|
25
|
+
const subscription = subscribeWithSnapshot((event) => {
|
|
26
|
+
seen.push(event.message.text);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(subscription.snapshot.some((message) => message.text === "racing-message")).toBe(true);
|
|
30
|
+
|
|
31
|
+
// listener 활성화
|
|
32
|
+
const unsubscribe = subscription.commit();
|
|
33
|
+
|
|
34
|
+
appendMessage("assistant", "after-subscribe");
|
|
35
|
+
expect(seen).toContain("after-subscribe");
|
|
36
|
+
|
|
37
|
+
unsubscribe();
|
|
38
|
+
});
|
|
39
|
+
});
|