@mandujs/cli 0.15.1 → 0.15.2
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 +48 -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 +177 -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 +73 -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 +88 -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 +151 -0
- package/templates/realtime-chat/tests/chat-starter.test.ts +149 -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,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,151 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { openChatStream } from "@/client/features/chat/chat-api";
|
|
3
|
+
import type { ChatStreamEvent } from "@/shared/contracts/chat";
|
|
4
|
+
|
|
5
|
+
type MessageEventLike = { data: 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) {
|
|
20
|
+
this.onmessage?.({ data });
|
|
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("notifies connection state changes and terminal failure", async () => {
|
|
70
|
+
const states: Array<"connecting" | "connected" | "reconnecting" | "failed" | "closed"> = [];
|
|
71
|
+
const sources: FakeEventSource[] = [];
|
|
72
|
+
|
|
73
|
+
const stop = openChatStream(() => {}, {
|
|
74
|
+
baseDelayMs: 5,
|
|
75
|
+
maxDelayMs: 5,
|
|
76
|
+
jitterRatio: 0,
|
|
77
|
+
maxRetries: 1,
|
|
78
|
+
onConnectionStateChange: (state) => {
|
|
79
|
+
states.push(state);
|
|
80
|
+
},
|
|
81
|
+
eventSourceFactory: (url) => {
|
|
82
|
+
const source = new FakeEventSource(url);
|
|
83
|
+
sources.push(source);
|
|
84
|
+
return source as unknown as EventSource;
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(states).toEqual(["connecting"]);
|
|
89
|
+
|
|
90
|
+
sources[0]?.emitOpen();
|
|
91
|
+
expect(states).toEqual(["connecting", "connected"]);
|
|
92
|
+
|
|
93
|
+
sources[0]?.emitError();
|
|
94
|
+
expect(states).toEqual(["connecting", "connected", "reconnecting"]);
|
|
95
|
+
|
|
96
|
+
await sleep(8);
|
|
97
|
+
expect(states).toEqual(["connecting", "connected", "reconnecting", "connecting"]);
|
|
98
|
+
|
|
99
|
+
sources[1]?.emitError();
|
|
100
|
+
expect(states).toEqual(["connecting", "connected", "reconnecting", "connecting", "failed"]);
|
|
101
|
+
|
|
102
|
+
stop();
|
|
103
|
+
expect(states).toEqual(["connecting", "connected", "reconnecting", "connecting", "failed", "closed"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("cleans up source and pending reconnect timer on stop", async () => {
|
|
107
|
+
const sources: FakeEventSource[] = [];
|
|
108
|
+
const stop = openChatStream(() => {}, {
|
|
109
|
+
baseDelayMs: 20,
|
|
110
|
+
maxDelayMs: 20,
|
|
111
|
+
jitterRatio: 0,
|
|
112
|
+
maxRetries: 3,
|
|
113
|
+
eventSourceFactory: (url) => {
|
|
114
|
+
const source = new FakeEventSource(url);
|
|
115
|
+
sources.push(source);
|
|
116
|
+
return source as unknown as EventSource;
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(sources.length).toBe(1);
|
|
121
|
+
|
|
122
|
+
sources[0]?.emitError();
|
|
123
|
+
stop();
|
|
124
|
+
|
|
125
|
+
await sleep(30);
|
|
126
|
+
expect(sources.length).toBe(1);
|
|
127
|
+
expect(sources[0]?.closed).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("forwards valid payload and ignores malformed payload", () => {
|
|
131
|
+
const events: ChatStreamEvent[] = [];
|
|
132
|
+
const sources: FakeEventSource[] = [];
|
|
133
|
+
|
|
134
|
+
const stop = openChatStream((event) => {
|
|
135
|
+
events.push(event);
|
|
136
|
+
}, {
|
|
137
|
+
eventSourceFactory: (url) => {
|
|
138
|
+
const source = new FakeEventSource(url);
|
|
139
|
+
sources.push(source);
|
|
140
|
+
return source as unknown as EventSource;
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
sources[0]?.emitMessage("{not-json");
|
|
145
|
+
sources[0]?.emitMessage(JSON.stringify({ type: "message" }));
|
|
146
|
+
|
|
147
|
+
expect(events).toEqual([{ type: "message" }]);
|
|
148
|
+
|
|
149
|
+
stop();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
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("includes essential ARIA attributes in chat UI", async () => {
|
|
137
|
+
const source = await Bun.file(
|
|
138
|
+
new URL("../src/client/features/chat/realtime-chat-starter.client.tsx", import.meta.url),
|
|
139
|
+
).text();
|
|
140
|
+
|
|
141
|
+
expect(source).toContain('role="log"');
|
|
142
|
+
expect(source).toContain('aria-live="polite"');
|
|
143
|
+
expect(source).toContain('aria-label="Chat messages"');
|
|
144
|
+
expect(source).toContain('aria-label="Chat message input"');
|
|
145
|
+
expect(source).toContain('aria-describedby="chat-input-description"');
|
|
146
|
+
expect(source).toContain('id="chat-input-description"');
|
|
147
|
+
expect(source).toContain('aria-label="Send message"');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -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((message) => {
|
|
26
|
+
seen.push(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
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Mandu Example Test
|
|
2
|
+
// 이 파일은 테스트 작성 방법을 보여주는 예제입니다.
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect } from "bun:test";
|
|
5
|
+
import { createTestRequest, parseJsonResponse, assertStatus } from "./helpers";
|
|
6
|
+
|
|
7
|
+
describe("Example Tests", () => {
|
|
8
|
+
describe("Basic Assertions", () => {
|
|
9
|
+
it("should pass basic equality test", () => {
|
|
10
|
+
expect(1 + 1).toBe(2);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("should pass object equality test", () => {
|
|
14
|
+
const obj = { status: "ok", data: { message: "hello" } };
|
|
15
|
+
expect(obj).toEqual({
|
|
16
|
+
status: "ok",
|
|
17
|
+
data: { message: "hello" },
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("Test Helpers", () => {
|
|
23
|
+
it("should create test request", () => {
|
|
24
|
+
const req = createTestRequest("http://localhost:3000/api/test", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
body: { name: "test" },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(req.method).toBe("POST");
|
|
30
|
+
expect(req.url).toBe("http://localhost:3000/api/test");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should parse JSON response", async () => {
|
|
34
|
+
const mockResponse = new Response(
|
|
35
|
+
JSON.stringify({ status: "ok" }),
|
|
36
|
+
{ status: 200 }
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const data = await parseJsonResponse<{ status: string }>(mockResponse);
|
|
40
|
+
expect(data.status).toBe("ok");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// API 핸들러 테스트 예제 (실제 핸들러 import 후 사용)
|
|
46
|
+
// import handler from "../.mandu/generated/server/routes/health.route";
|
|
47
|
+
//
|
|
48
|
+
// describe("API: GET /api/health", () => {
|
|
49
|
+
// it("should return 200 with status ok", async () => {
|
|
50
|
+
// const req = createTestRequest("http://localhost:3000/api/health");
|
|
51
|
+
// const response = handler(req, {});
|
|
52
|
+
//
|
|
53
|
+
// assertStatus(response, 200);
|
|
54
|
+
//
|
|
55
|
+
// const data = await parseJsonResponse<{ status: string }>(response);
|
|
56
|
+
// expect(data.status).toBe("ok");
|
|
57
|
+
// });
|
|
58
|
+
// });
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Mandu Test Helpers
|
|
2
|
+
// 테스트에서 사용할 유틸리티 함수들
|
|
3
|
+
|
|
4
|
+
import type { Request } from "bun";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* API 핸들러 테스트용 Request 생성
|
|
8
|
+
*/
|
|
9
|
+
export function createTestRequest(
|
|
10
|
+
url: string,
|
|
11
|
+
options?: {
|
|
12
|
+
method?: string;
|
|
13
|
+
body?: unknown;
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
): Request {
|
|
17
|
+
const { method = "GET", body, headers = {} } = options || {};
|
|
18
|
+
|
|
19
|
+
return new Request(url, {
|
|
20
|
+
method,
|
|
21
|
+
headers: {
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
...headers,
|
|
24
|
+
},
|
|
25
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Response를 JSON으로 파싱
|
|
31
|
+
*/
|
|
32
|
+
export async function parseJsonResponse<T = unknown>(response: Response): Promise<T> {
|
|
33
|
+
return response.json() as Promise<T>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Response 상태 검증
|
|
38
|
+
*/
|
|
39
|
+
export function assertStatus(response: Response, expectedStatus: number): void {
|
|
40
|
+
if (response.status !== expectedStatus) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Expected status ${expectedStatus}, got ${response.status}`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 테스트용 라우트 파라미터 생성
|
|
49
|
+
*/
|
|
50
|
+
export function createParams(params: Record<string, string>): Record<string, string> {
|
|
51
|
+
return params;
|
|
52
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"jsx": "react-jsx",
|
|
10
|
+
"types": ["bun-types"],
|
|
11
|
+
"baseUrl": ".",
|
|
12
|
+
"paths": {
|
|
13
|
+
"@/*": ["./src/*"]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"include": [
|
|
17
|
+
"app/**/*.ts",
|
|
18
|
+
"app/**/*.tsx",
|
|
19
|
+
"src/**/*.ts",
|
|
20
|
+
"src/**/*.tsx"
|
|
21
|
+
],
|
|
22
|
+
"exclude": ["node_modules"]
|
|
23
|
+
}
|