saeeol 1.3.1 → 1.4.1

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.
@@ -0,0 +1,520 @@
1
+ /**
2
+ * tui-walkthrough.test.ts — SAEEOL TUI 전체 워크스루
3
+ *
4
+ * bun-pty로 TUI를 띄우고 모든 화면/단축키를 자동으로 눌러서:
5
+ * 1. 에러 없이 렌더링되는지
6
+ * 2. 각 다이얼로그가 열리고 닫히는지
7
+ * 3. 슬래시 명령이 동작하는지
8
+ * 4. 화면 전환이 정상인지
9
+ * 5. 세션 생성 → 메시지 흐름이 되는지
10
+ *
11
+ * API 호출/비용 발생 없음 — LLM 엔드포인트는 죽은 서버로 설정.
12
+ */
13
+
14
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test"
15
+ import { spawn as PtySpawn, type IPty } from "bun-pty"
16
+ import { tmpdir, disposeAllInstances } from "../fixture/fixture"
17
+ import * as Log from "@saeeol/core/util/log"
18
+ import { Flag } from "@saeeol/core/flag/flag"
19
+ import path from "path"
20
+
21
+ void Log.init({ print: false })
22
+
23
+ const BIN = path.resolve(import.meta.dir, "../../bin/saeeol.cjs")
24
+
25
+ // ── 유틸리티 ──
26
+
27
+ function stripAnsi(s: string): string {
28
+ return s
29
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
30
+ .replace(/\x1b\].*?\x07/g, "")
31
+ .replace(/\x1b\[.*?m/g, "")
32
+ .replace(/[\x00-\x09\x0b\x0c\x0e-\x1f]/g, "")
33
+ }
34
+
35
+ // Known issues — 이 패턴은 무시 (해결되면 제거)
36
+ const KNOWN_ISSUES = [
37
+ /export named 'jsx' not found in module.*@opentui\/solid\/jsx-runtime/i,
38
+ /e=Export named 'jsx' not found.*exception/i,
39
+ ]
40
+
41
+ function hasError(plain: string): string[] {
42
+ return plain
43
+ .split("\n")
44
+ .filter((l) => {
45
+ const t = l.trim()
46
+ if (!t) return false
47
+ // known issue면 무조건 스킵
48
+ if (KNOWN_ISSUES.some((re) => re.test(t))) return false
49
+ return (
50
+ /unhandled|TypeError|ReferenceError|SyntaxError|Cannot find module|Error:/i.test(t) ||
51
+ /exception/i.test(t) ||
52
+ /\be=\b.*not found/i.test(t) ||
53
+ /crashed|fatal|panic/i.test(t)
54
+ )
55
+ })
56
+ }
57
+
58
+ // ── TUI 드라이버 ──
59
+
60
+ class TuiDriver {
61
+ pty: IPty
62
+ output = ""
63
+ snapshots: Array<{ name: string; raw: string; plain: string }> = []
64
+
65
+ constructor(cwd: string) {
66
+ this.pty = PtySpawn("node", [BIN, "tui"], {
67
+ name: "xterm-256color",
68
+ cols: 120,
69
+ rows: 30,
70
+ cwd,
71
+ env: {
72
+ ...process.env,
73
+ TERM: "xterm-256color",
74
+ FORCE_COLOR: "1",
75
+ SAEEOL_LLM_PROVIDER: "custom",
76
+ SAEEOL_LLM_BASE_URL: "http://localhost:1",
77
+ SAEEOL_LLM_API_KEY: "test",
78
+ SAEEOL_LLM_MODEL: "test",
79
+ },
80
+ })
81
+ this.pty.onData((data) => {
82
+ this.output += data
83
+ })
84
+ }
85
+
86
+ write(s: string) {
87
+ this.pty.write(s)
88
+ }
89
+
90
+ async wait(ms: number) {
91
+ await Bun.sleep(ms)
92
+ }
93
+
94
+ snapshot(name: string) {
95
+ const raw = this.output
96
+ const plain = stripAnsi(this.output)
97
+ this.snapshots.push({ name, raw, plain })
98
+ return { raw, plain }
99
+ }
100
+
101
+ errors(): string[] {
102
+ return hasError(stripAnsi(this.output))
103
+ }
104
+
105
+ async kill() {
106
+ try {
107
+ this.pty.kill()
108
+ } catch {}
109
+ }
110
+
111
+ /** 특정 텍스트가 화면에 나타날 때까지 대기 */
112
+ async waitFor(text: string, timeoutMs = 10000): Promise<boolean> {
113
+ const start = Date.now()
114
+ while (Date.now() - start < timeoutMs) {
115
+ if (stripAnsi(this.output).includes(text)) return true
116
+ await Bun.sleep(200)
117
+ }
118
+ return false
119
+ }
120
+
121
+ /** leader 키 (ctrl+x) + 후속 키 */
122
+ async leader(key: string, delay = 100) {
123
+ this.write("\x18") // ctrl+x
124
+ await this.wait(delay)
125
+ this.write(key)
126
+ }
127
+
128
+ /** 에스케이프로 다이얼로그 닫기 */
129
+ async dismiss(delay = 500) {
130
+ this.write("\x1b") // escape
131
+ await this.wait(delay)
132
+ }
133
+ }
134
+
135
+ // ── 테스트 ──
136
+
137
+ describe("TUI walkthrough: full interaction", () => {
138
+ let driver: TuiDriver
139
+ let tmp: Awaited<ReturnType<typeof tmpdir>>
140
+
141
+ beforeAll(async () => {
142
+ tmp = await tmpdir({ git: true, config: {} })
143
+ Flag.SAEEOL_EXPERIMENTAL_HTTPAPI = false
144
+ })
145
+
146
+ afterAll(async () => {
147
+ await driver?.kill()
148
+ await disposeAllInstances()
149
+ Flag.SAEEOL_EXPERIMENTAL_HTTPAPI = true
150
+ })
151
+
152
+ // ── 1. 부트스트랩 ──
153
+
154
+ test("boot: TUI starts without errors", async () => {
155
+ driver = new TuiDriver(tmp.path)
156
+
157
+ // 서버 시작 + 초기 렌더링 대기
158
+ await driver.wait(6000)
159
+ driver.snapshot("boot")
160
+
161
+ // 에러 없음
162
+ expect(driver.errors()).toHaveLength(0)
163
+
164
+ // alternate screen 진입
165
+ expect(driver.output).toMatch(/\x1b\[\?9001h|\x1b\[\?1004h/)
166
+ })
167
+
168
+ // ── 2. Home 화면 ──
169
+
170
+ test("home: renders content", async () => {
171
+ const { plain } = driver.snapshot("home")
172
+ expect(plain.length).toBeGreaterThan(0)
173
+ expect(driver.errors()).toHaveLength(0)
174
+ })
175
+
176
+ // ── 3. 커맨드 팔레트 (ctrl+p) ──
177
+
178
+ test("command palette: ctrl+p opens, esc closes", async () => {
179
+ driver.write("\x10") // ctrl+p
180
+ await driver.wait(1000)
181
+ driver.snapshot("command-palette-open")
182
+ expect(driver.errors()).toHaveLength(0)
183
+
184
+ await driver.dismiss()
185
+ driver.snapshot("command-palette-closed")
186
+ expect(driver.errors()).toHaveLength(0)
187
+ })
188
+
189
+ // ── 4. 세션 리스트 (leader+l) ──
190
+
191
+ test("session list: leader+l opens dialog", async () => {
192
+ await driver.leader("l")
193
+ await driver.wait(1500)
194
+ driver.snapshot("session-list")
195
+ expect(driver.errors()).toHaveLength(0)
196
+
197
+ await driver.dismiss()
198
+ })
199
+
200
+ // ── 5. 모델 선택 (leader+m) ──
201
+
202
+ test("model selector: leader+m opens dialog", async () => {
203
+ await driver.leader("m")
204
+ await driver.wait(1500)
205
+ driver.snapshot("model-selector")
206
+ expect(driver.errors()).toHaveLength(0)
207
+
208
+ await driver.dismiss()
209
+ })
210
+
211
+ // ── 6. 에이전트 선택 (leader+a) ──
212
+
213
+ test("agent selector: leader+a opens dialog", async () => {
214
+ await driver.leader("a")
215
+ await driver.wait(1500)
216
+ driver.snapshot("agent-selector")
217
+ expect(driver.errors()).toHaveLength(0)
218
+
219
+ await driver.dismiss()
220
+ })
221
+
222
+ // ── 7. 상태 다이얼로그 (leader+s) ──
223
+
224
+ test("status dialog: leader+s opens", async () => {
225
+ await driver.leader("s")
226
+ await driver.wait(1500)
227
+ driver.snapshot("status")
228
+ expect(driver.errors()).toHaveLength(0)
229
+
230
+ await driver.dismiss()
231
+ })
232
+
233
+ // ── 8. 테마 선택 (leader+t) ──
234
+
235
+ test("theme selector: leader+t opens", async () => {
236
+ await driver.leader("t")
237
+ await driver.wait(1500)
238
+ driver.snapshot("theme")
239
+ expect(driver.errors()).toHaveLength(0)
240
+
241
+ await driver.dismiss()
242
+ })
243
+
244
+ // ── 9. 슬래시 명령어 ──
245
+
246
+ test("slash /help: opens help dialog", async () => {
247
+ driver.write("/help")
248
+ await driver.wait(500)
249
+ driver.write("\r") // enter
250
+ await driver.wait(1500)
251
+ driver.snapshot("slash-help")
252
+ expect(driver.errors()).toHaveLength(0)
253
+
254
+ await driver.dismiss()
255
+ })
256
+
257
+ test("slash /status: opens status", async () => {
258
+ driver.write("/status")
259
+ await driver.wait(500)
260
+ driver.write("\r")
261
+ await driver.wait(1500)
262
+ driver.snapshot("slash-status")
263
+ expect(driver.errors()).toHaveLength(0)
264
+
265
+ await driver.dismiss()
266
+ })
267
+
268
+ test("slash /models: opens model dialog", async () => {
269
+ driver.write("/models")
270
+ await driver.wait(500)
271
+ driver.write("\r")
272
+ await driver.wait(1500)
273
+ driver.snapshot("slash-models")
274
+ expect(driver.errors()).toHaveLength(0)
275
+
276
+ await driver.dismiss()
277
+ })
278
+
279
+ test("slash /agents: opens agent dialog", async () => {
280
+ driver.write("/agents")
281
+ await driver.wait(500)
282
+ driver.write("\r")
283
+ await driver.wait(1500)
284
+ driver.snapshot("slash-agents")
285
+ expect(driver.errors()).toHaveLength(0)
286
+
287
+ await driver.dismiss()
288
+ })
289
+
290
+ test("slash /themes: opens theme dialog", async () => {
291
+ driver.write("/themes")
292
+ await driver.wait(500)
293
+ driver.write("\r")
294
+ await driver.wait(1500)
295
+ driver.snapshot("slash-themes")
296
+ expect(driver.errors()).toHaveLength(0)
297
+
298
+ await driver.dismiss()
299
+ })
300
+
301
+ test("slash /sessions: opens session list", async () => {
302
+ driver.write("/sessions")
303
+ await driver.wait(500)
304
+ driver.write("\r")
305
+ await driver.wait(1500)
306
+ driver.snapshot("slash-sessions")
307
+ expect(driver.errors()).toHaveLength(0)
308
+
309
+ await driver.dismiss()
310
+ })
311
+
312
+ test("slash /connect: opens provider dialog", async () => {
313
+ driver.write("/connect")
314
+ await driver.wait(500)
315
+ driver.write("\r")
316
+ await driver.wait(1500)
317
+ driver.snapshot("slash-connect")
318
+ expect(driver.errors()).toHaveLength(0)
319
+
320
+ await driver.dismiss()
321
+ })
322
+
323
+ test("slash /mcps: opens MCP dialog", async () => {
324
+ driver.write("/mcps")
325
+ await driver.wait(500)
326
+ driver.write("\r")
327
+ await driver.wait(1500)
328
+ driver.snapshot("slash-mcps")
329
+ expect(driver.errors()).toHaveLength(0)
330
+
331
+ await driver.dismiss()
332
+ })
333
+
334
+ test("slash /variants: opens variant dialog", async () => {
335
+ driver.write("/variants")
336
+ await driver.wait(500)
337
+ driver.write("\r")
338
+ await driver.wait(1500)
339
+ driver.snapshot("slash-variants")
340
+ expect(driver.errors()).toHaveLength(0)
341
+
342
+ await driver.dismiss()
343
+ })
344
+
345
+ test("slash /new: navigates home", async () => {
346
+ driver.write("/new")
347
+ await driver.wait(500)
348
+ driver.write("\r")
349
+ await driver.wait(1000)
350
+ driver.snapshot("slash-new")
351
+ expect(driver.errors()).toHaveLength(0)
352
+ })
353
+
354
+ // ── 10. 프롬프트 입력 ──
355
+
356
+ test("prompt: typing and clearing", async () => {
357
+ driver.write("hello world")
358
+ await driver.wait(500)
359
+ driver.snapshot("prompt-typed")
360
+ expect(driver.errors()).toHaveLength(0)
361
+
362
+ // ctrl+c로 클리어
363
+ driver.write("\x03")
364
+ await driver.wait(300)
365
+ driver.snapshot("prompt-cleared")
366
+ expect(driver.errors()).toHaveLength(0)
367
+ })
368
+
369
+ // ── 11. 새 세션 (leader+n) ──
370
+
371
+ test("new session: leader+n navigates home", async () => {
372
+ await driver.leader("n")
373
+ await driver.wait(1000)
374
+ driver.snapshot("new-session")
375
+ expect(driver.errors()).toHaveLength(0)
376
+ })
377
+
378
+ // ── 12. 사이드바 토글 (leader+b) ──
379
+
380
+ test("sidebar: leader+b toggles", async () => {
381
+ await driver.leader("b")
382
+ await driver.wait(800)
383
+ driver.snapshot("sidebar-open")
384
+ expect(driver.errors()).toHaveLength(0)
385
+
386
+ // 다시 토글
387
+ await driver.leader("b")
388
+ await driver.wait(500)
389
+ driver.snapshot("sidebar-closed")
390
+ expect(driver.errors()).toHaveLength(0)
391
+ })
392
+
393
+ // ── 13. 스크롤 키 ──
394
+
395
+ test("scroll: page up/down work", async () => {
396
+ driver.write("\x1b[5~") // page up
397
+ await driver.wait(300)
398
+ driver.write("\x1b[6~") // page down
399
+ await driver.wait(300)
400
+ driver.snapshot("scroll")
401
+ expect(driver.errors()).toHaveLength(0)
402
+ })
403
+
404
+ // ── 14. 모델 사이클 (f2) ──
405
+
406
+ test("model cycle: f2 cycles models", async () => {
407
+ // f2 키 시퀀스
408
+ driver.write("\x1bOQ") // f2 (xterm)
409
+ await driver.wait(500)
410
+ driver.snapshot("model-cycle-f2")
411
+ expect(driver.errors()).toHaveLength(0)
412
+ })
413
+
414
+ // ── 15. 에이전트 사이클 (tab) ──
415
+
416
+ test("agent cycle: tab cycles agents", async () => {
417
+ driver.write("\t") // tab
418
+ await driver.wait(500)
419
+ driver.snapshot("agent-cycle-tab")
420
+ expect(driver.errors()).toHaveLength(0)
421
+ })
422
+
423
+ // ── 16. 세션 콤팩트 (leader+c) ──
424
+
425
+ test("compact: leader+c triggers compact", async () => {
426
+ await driver.leader("c")
427
+ await driver.wait(1000)
428
+ driver.snapshot("session-compact")
429
+ expect(driver.errors()).toHaveLength(0)
430
+ })
431
+
432
+ // ── 17. 세션 타임라인 (leader+g) ──
433
+
434
+ test("timeline: leader+g opens timeline", async () => {
435
+ await driver.leader("g")
436
+ await driver.wait(1000)
437
+ driver.snapshot("session-timeline")
438
+ expect(driver.errors()).toHaveLength(0)
439
+
440
+ await driver.dismiss()
441
+ })
442
+
443
+ // ── 18. 커서 이동 키 ──
444
+
445
+ test("cursor: arrow keys and home/end", async () => {
446
+ // 입력창에 텍스트 입력 후 커서 이동
447
+ driver.write("test input for cursor movement")
448
+ await driver.wait(300)
449
+
450
+ driver.write("\x1b[D") // left
451
+ await driver.wait(100)
452
+ driver.write("\x1b[C") // right
453
+ await driver.wait(100)
454
+ driver.write("\x1b[H") // home
455
+ await driver.wait(100)
456
+ driver.write("\x1b[F") // end
457
+ await driver.wait(100)
458
+
459
+ driver.snapshot("cursor-movement")
460
+ expect(driver.errors()).toHaveLength(0)
461
+
462
+ // 클리어
463
+ driver.write("\x03")
464
+ await driver.wait(300)
465
+ })
466
+
467
+ // ── 19. 슬래시 명령어 — 별칭 ──
468
+
469
+ test("slash /quit: alias for /exit", async () => {
470
+ driver.write("/quit")
471
+ await driver.wait(500)
472
+ driver.write("\r")
473
+ await driver.wait(2000)
474
+ driver.snapshot("slash-quit")
475
+
476
+ // /quit이 /exit과 동일하게 동작하면 TUI가 종료되어야 함
477
+ // 종료되면 에러 없이 프로세스가 끝남
478
+ const exited = await Promise.race([
479
+ new Promise<boolean>((resolve) => {
480
+ driver.pty.onExit(() => resolve(true))
481
+ setTimeout(() => resolve(false), 3000)
482
+ }),
483
+ ])
484
+
485
+ if (exited) {
486
+ // 정상 종료 — 드라이버를 다시 만들 필요 없음 (이후 테스트 없음)
487
+ expect(driver.errors()).toHaveLength(0)
488
+ } else {
489
+ // 종료 안 됨 — 에스케이프 후 계속
490
+ await driver.dismiss()
491
+ expect(driver.errors()).toHaveLength(0)
492
+ }
493
+ })
494
+
495
+ // ── 20. 전체 스냅샷 검증 ──
496
+
497
+ test("all snapshots: no errors in any screen", async () => {
498
+ const allErrors: string[] = []
499
+ for (const snap of driver.snapshots) {
500
+ const snapErrors = hasError(snap.plain)
501
+ if (snapErrors.length > 0) {
502
+ allErrors.push(
503
+ `[${snap.name}]: ${snapErrors.join("; ")}`,
504
+ )
505
+ }
506
+ }
507
+
508
+ // 디버그: 전체 스냅샷 로그 저장
509
+ const reportPath = path.join(import.meta.dir, ".tui-walkthrough-report.txt")
510
+ const report = driver.snapshots
511
+ .map(
512
+ (s) =>
513
+ `=== ${s.name} (${s.raw.length} bytes) ===\n${s.plain.slice(-500)}`,
514
+ )
515
+ .join("\n\n")
516
+ await Bun.write(reportPath, report)
517
+
518
+ expect(allErrors).toHaveLength(0)
519
+ })
520
+ })
@@ -1 +0,0 @@
1
- $ tsgo --noEmit