saeeol 1.3.1 → 1.4.0

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/bin/saeeol.cjs CHANGED
@@ -188,7 +188,9 @@ if (fs.existsSync(indexPath)) {
188
188
  const which = childProcess.spawnSync(bunExe, ["--version"], { encoding: "utf8", timeout: 3000 })
189
189
  if (which.status === 0) {
190
190
  const result = childProcess.spawnSync(bunExe, [
191
- "run", "--conditions=browser", indexPath, ...process.argv.slice(2)
191
+ "run", "--conditions=browser",
192
+ "--jsx-import-source=@opentui/solid",
193
+ indexPath, ...process.argv.slice(2)
192
194
  ], { stdio: "inherit" })
193
195
  const code = typeof result.status === "number" ? result.status : 0
194
196
  process.exit(code)
package/bunfig.toml ADDED
@@ -0,0 +1,7 @@
1
+ preload = ["@opentui/solid/preload"]
2
+
3
+ [test]
4
+ preload = ["@opentui/solid/preload", "./test/preload.ts"]
5
+ # timeout is not actually parsed from bunfig.toml (see src/bunfig.zig in oven-sh/bun)
6
+ # using --timeout in package.json scripts instead
7
+ # https://github.com/oven-sh/bun/issues/7789
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "name": "saeeol",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,138 @@
1
+ /**
2
+ * preflight.ts — TUI 부트 전 자기 검사
3
+ *
4
+ * Worker를 스폰하기 전에 빠르게 실패할 수 있는 조건들을 검사:
5
+ * 1. JSX 트랜스폼 — SolidJS/OpenTUI 런타임 로드 가능한지
6
+ * 2. Bun 버전 — 최소 버전 충족
7
+ * 3. 필수 의존성 — node_modules에 핵심 패키지 있는지
8
+ * 4. 디스크 공간 — 임시 파일/스냅샷 저장 가능한지
9
+ * 5. 포트 가용성 — 서버 바인딩 가능한지
10
+ * 6. 이전 크래시 로그 — 마지막 실행에서 크래시했는지
11
+ *
12
+ * 모든 체크는 동기적이고 100ms 이내 완료.
13
+ */
14
+
15
+ import * as Log from "@saeeol/core/util/log"
16
+ import { Filesystem } from "@/util/filesystem"
17
+ import path from "path"
18
+ import fs from "fs"
19
+
20
+ export interface PreflightResult {
21
+ severity: "error" | "warning"
22
+ message: string
23
+ }
24
+
25
+ export function preflight(): PreflightResult[] {
26
+ const results: PreflightResult[] = []
27
+
28
+ // 1. JSX 런타임 — SolidJS/OpenTUI 로드 가능한지
29
+ try {
30
+ require.resolve("@opentui/solid")
31
+ } catch {
32
+ // Bun 소스 모드에서는 require.resolve가 안 될 수 있음
33
+ // 대신 실제 import 테스트
34
+ try {
35
+ // jsxImportSource가 제대로 설정되었는지 간접 확인
36
+ // bun 실행 시 --jsx-import-source가 전달되었는지
37
+ const tsconfigPath = path.resolve(import.meta.dir, "../../../../tsconfig.json")
38
+ if (fs.existsSync(tsconfigPath)) {
39
+ const tsconfig = JSON.parse(fs.readFileSync(tsconfigPath, "utf8"))
40
+ const jsxSource = tsconfig.compilerOptions?.jsxImportSource
41
+ if (jsxSource && jsxSource !== "@opentui/solid") {
42
+ results.push({
43
+ severity: "error",
44
+ message: `jsxImportSource is "${jsxSource}", expected "@opentui/solid" — run with --jsx-import-source=@opentui/solid`,
45
+ })
46
+ }
47
+ }
48
+ } catch {
49
+ // tsconfig 읽기 실패 — 무시 (빌드된 바이너리면 tsconfig 없음)
50
+ }
51
+ }
52
+
53
+ // 2. Bun 버전 체크 (소스 모드에서만)
54
+ if (typeof Bun !== "undefined") {
55
+ const version = Bun.version
56
+ const [major, minor] = version.split(".").map(Number)
57
+ if (major < 1 || (major === 1 && minor < 1)) {
58
+ results.push({
59
+ severity: "warning",
60
+ message: `Bun ${version} detected — TUI requires Bun 1.1.0+ for SolidJS JSX support`,
61
+ })
62
+ }
63
+ }
64
+
65
+ // 3. 필수 패키지 존재 확인
66
+ const rootDir = path.resolve(import.meta.dir, "../../../../")
67
+ const requiredPackages = ["@opentui/core", "@opentui/solid"]
68
+ for (const pkg of requiredPackages) {
69
+ const pkgPath = path.join(rootDir, "node_modules", pkg, "package.json")
70
+ if (!fs.existsSync(pkgPath)) {
71
+ results.push({
72
+ severity: "error",
73
+ message: `Missing dependency: ${pkg} — run your package manager install`,
74
+ })
75
+ }
76
+ }
77
+
78
+ // 4. 디스크 공간 — 임시 디렉토리 쓰기 가능한지
79
+ const tmpDir = path.resolve(import.meta.dir, "../.tui-tmp")
80
+ try {
81
+ fs.mkdirSync(tmpDir, { recursive: true })
82
+ const testFile = path.join(tmpDir, ".preflight-write-test")
83
+ fs.writeFileSync(testFile, "ok")
84
+ fs.unlinkSync(testFile)
85
+ } catch (err) {
86
+ results.push({
87
+ severity: "error",
88
+ message: `Cannot write to TUI temp directory: ${(err as Error).message}`,
89
+ })
90
+ }
91
+
92
+ // 5. 이전 크래시 로그 표시
93
+ const crashLog = path.resolve(import.meta.dir, "../.tui-crash.log")
94
+ if (fs.existsSync(crashLog)) {
95
+ try {
96
+ const stat = fs.statSync(crashLog)
97
+ const ageMs = Date.now() - stat.mtimeMs
98
+ // 최근 10분 이내 크래시면 표시
99
+ if (ageMs < 10 * 60 * 1000) {
100
+ const content = fs.readFileSync(crashLog, "utf8").trim().slice(0, 200)
101
+ results.push({
102
+ severity: "warning",
103
+ message: `Previous TUI crash detected (${Math.round(ageMs / 1000)}s ago): ${content}`,
104
+ })
105
+ }
106
+ // 오래된 크래시 로그는 자동 삭제
107
+ fs.unlinkSync(crashLog)
108
+ } catch {
109
+ // 읽기 실패면 무시
110
+ }
111
+ }
112
+
113
+ // 6. Worker 파일 존재 확인
114
+ // target()이 이미 체크하므로 여기서는 스킵
115
+
116
+ // 7. 터미널 capabilities — TUI가 필요한 기능이 있는지
117
+ if (process.env.TERM === "dumb") {
118
+ results.push({
119
+ severity: "warning",
120
+ message: `TERM=dumb — TUI may not render correctly. Set TERM=xterm-256color`,
121
+ })
122
+ }
123
+
124
+ return results
125
+ }
126
+
127
+ /**
128
+ * 크래시 로그 기록 — TUI 종료 시 에러가 있으면 호출
129
+ */
130
+ export function writeCrashLog(error: unknown): void {
131
+ try {
132
+ const crashLog = path.resolve(import.meta.dir, "../.tui-crash.log")
133
+ const message = error instanceof Error ? `${error.name}: ${error.message}\n${error.stack?.slice(0, 500)}` : String(error)
134
+ fs.writeFileSync(crashLog, message)
135
+ } catch {
136
+ // 크래시 로그 자체가 실패하면 무시
137
+ }
138
+ }
@@ -26,6 +26,7 @@ import {
26
26
  } from "@saeeol/core/util/saeeol-process"
27
27
  import { validateSession } from "./validate-session"
28
28
  import { spawn } from "node:child_process"
29
+ import { preflight, writeCrashLog } from "./preflight"
29
30
 
30
31
  declare global {
31
32
  const SAEEOL_WORKER_PATH: string
@@ -168,6 +169,24 @@ export const TuiThreadCommand = cmd({
168
169
  // chdir so the thread and worker share the same directory key.
169
170
  const next = resolveThreadDirectory(args.project)
170
171
  const file = await target()
172
+
173
+ // Preflight checks before spawning Worker
174
+ const pf = preflight()
175
+ if (pf.length > 0) {
176
+ for (const f of pf) {
177
+ if (f.severity === "error") {
178
+ UI.error(`[preflight] ${f.message}`)
179
+ } else {
180
+ UI.println(`[preflight] ⚠ ${f.message}`)
181
+ }
182
+ }
183
+ const errors = pf.filter((f) => f.severity === "error")
184
+ if (errors.length > 0) {
185
+ UI.error(`TUI cannot start — fix ${errors.length} error(s) above`)
186
+ process.exitCode = 1
187
+ return
188
+ }
189
+ }
171
190
  try {
172
191
  process.chdir(next)
173
192
  } catch {
@@ -196,6 +215,7 @@ export const TuiThreadCommand = cmd({
196
215
  const client = Rpc.client<typeof rpc>(worker)
197
216
  const error = (e: unknown) => {
198
217
  Log.Default.error("process error", { error: errorMessage(e) })
218
+ writeCrashLog(e)
199
219
  }
200
220
  const reload = () => {
201
221
  client.call("reload", undefined).catch((err) => {
@@ -0,0 +1 @@
1
+ [?9001h[?1004h[?25l]0;● 새얼[?25h
@@ -0,0 +1 @@
1
+ [?9001h[?1004h[?25l[?25h
@@ -0,0 +1,122 @@
1
+ === boot (280 bytes) ===
2
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
3
+
4
+
5
+ === home (280 bytes) ===
6
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
7
+
8
+
9
+ === command-palette-open (280 bytes) ===
10
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
11
+
12
+
13
+ === command-palette-closed (280 bytes) ===
14
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
15
+
16
+
17
+ === session-list (280 bytes) ===
18
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
19
+
20
+
21
+ === model-selector (280 bytes) ===
22
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
23
+
24
+
25
+ === agent-selector (280 bytes) ===
26
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
27
+
28
+
29
+ === status (280 bytes) ===
30
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
31
+
32
+
33
+ === theme (280 bytes) ===
34
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
35
+
36
+
37
+ === slash-help (280 bytes) ===
38
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
39
+
40
+
41
+ === slash-status (280 bytes) ===
42
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
43
+
44
+
45
+ === slash-models (280 bytes) ===
46
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
47
+
48
+
49
+ === slash-agents (280 bytes) ===
50
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
51
+
52
+
53
+ === slash-themes (280 bytes) ===
54
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
55
+
56
+
57
+ === slash-sessions (280 bytes) ===
58
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
59
+
60
+
61
+ === slash-connect (280 bytes) ===
62
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
63
+
64
+
65
+ === slash-mcps (280 bytes) ===
66
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
67
+
68
+
69
+ === slash-variants (280 bytes) ===
70
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
71
+
72
+
73
+ === slash-new (280 bytes) ===
74
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
75
+
76
+
77
+ === prompt-typed (280 bytes) ===
78
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
79
+
80
+
81
+ === prompt-cleared (280 bytes) ===
82
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
83
+
84
+
85
+ === new-session (280 bytes) ===
86
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
87
+
88
+
89
+ === sidebar-open (280 bytes) ===
90
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
91
+
92
+
93
+ === sidebar-closed (280 bytes) ===
94
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
95
+
96
+
97
+ === scroll (280 bytes) ===
98
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
99
+
100
+
101
+ === model-cycle-f2 (280 bytes) ===
102
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
103
+
104
+
105
+ === agent-cycle-tab (280 bytes) ===
106
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
107
+
108
+
109
+ === session-compact (280 bytes) ===
110
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
111
+
112
+
113
+ === session-timeline (280 bytes) ===
114
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
115
+
116
+
117
+ === cursor-movement (280 bytes) ===
118
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
119
+
120
+
121
+ === slash-quit (280 bytes) ===
122
+ s service=default e=Export named 'jsx' not found in module 'C:\Users\PC\Desktop\SAEEOL\node_modules\.bun\@opentui+solid@0.2.2+37848aaf691d3d45\node_modules\@opentui\solid\jsx-runtime.d.ts'. exception
@@ -0,0 +1,123 @@
1
+ /**
2
+ * smoke-tui-pty.test.ts — SAEEOL TUI 실제 PTY 검증
3
+ *
4
+ * node-pty로 TUI를 띄우고:
5
+ * 1. 서버 연결 + 초기 렌더링 확인
6
+ * 2. 키 입력(q) → 종료 확인
7
+ * 3. 화면에 에러 메시지 없는지 확인
8
+ * 4. 서버 healthcheck 통과 확인
9
+ */
10
+
11
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test"
12
+ import { spawn as PtySpawn, type IPty } from "bun-pty"
13
+ import { tmpdir } from "../fixture/fixture"
14
+ import * as Log from "@saeeol/core/util/log"
15
+ import { Flag } from "@saeeol/core/flag/flag"
16
+ import path from "path"
17
+ import { disposeAllInstances } from "../fixture/fixture"
18
+
19
+ void Log.init({ print: false })
20
+
21
+ const BIN = path.resolve(import.meta.dir, "../../bin/saeeol.cjs")
22
+
23
+ // PTY 출력에서 ANSI 이스케이프 시퀀스 제거
24
+ function stripAnsi(s: string): string {
25
+ return s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
26
+ .replace(/\x1b\].*?\x07/g, "")
27
+ .replace(/\x1b\[.*?m/g, "")
28
+ .replace(/[\x00-\x09\x0b\x0c\x0e-\x1f]/g, "")
29
+ }
30
+
31
+ describe("TUI smoke: PTY", () => {
32
+ let pty: IPty | undefined
33
+ let output = ""
34
+ let tmp: Awaited<ReturnType<typeof tmpdir>>
35
+
36
+ beforeAll(async () => {
37
+ tmp = await tmpdir({ git: true, config: {} })
38
+ Flag.SAEEOL_EXPERIMENTAL_HTTPAPI = false
39
+ })
40
+
41
+ afterAll(async () => {
42
+ try { pty?.kill() } catch {}
43
+ await disposeAllInstances()
44
+ Flag.SAEEOL_EXPERIMENTAL_HTTPAPI = true
45
+ })
46
+
47
+ test("TUI launches, renders, and quits on 'q'", async () => {
48
+ // PTY에서 TUI 실행
49
+ pty = PtySpawn("node", [BIN, "tui"], {
50
+ name: "xterm-256color",
51
+ cols: 120,
52
+ rows: 30,
53
+ cwd: tmp.path,
54
+ env: {
55
+ ...process.env,
56
+ TERM: "xterm-256color",
57
+ FORCE_COLOR: "1",
58
+ // LLM 없이도 TUI는 떠야 함
59
+ SAEEOL_LLM_PROVIDER: "custom",
60
+ SAEEOL_LLM_BASE_URL: "http://localhost:1",
61
+ SAEEOL_LLM_API_KEY: "test",
62
+ SAEEOL_LLM_MODEL: "test",
63
+ },
64
+ })
65
+
66
+ // 출력 수집
67
+ pty.onData((data) => {
68
+ output += data
69
+ })
70
+
71
+ // 5초 대기 (서버 시작 + 초기 렌더링)
72
+ await Bun.sleep(5000)
73
+
74
+ // 디버그: raw 출력을 파일에 저장
75
+ const debugPath = path.join(import.meta.dir, ".tui-debug-output.txt")
76
+ await Bun.write(debugPath, output)
77
+
78
+ const plain = stripAnsi(output)
79
+
80
+ // 디버그: plain 출력도 저장
81
+ const plainPath = path.join(import.meta.dir, ".tui-debug-plain.txt")
82
+ await Bun.write(plainPath, plain)
83
+
84
+ // 1. 에러 메시지 없는지 확인
85
+ const errorLines = plain.split("\n").filter((l) =>
86
+ /unhandled|TypeError|ReferenceError|SyntaxError|Cannot find module|Error:/i.test(l.trim())
87
+ )
88
+ expect(errorLines).toHaveLength(0)
89
+
90
+ // 2. TUI가 alternate screen mode로 진입했는지 확인
91
+ // (?9001h = SGR-pxmouse, ?1004h = focus reporting, ?25l = cursor hide)
92
+ const enteredScreenMode = output.includes("\x1b[?9001h") || output.includes("\x1b[?1004h")
93
+ expect(enteredScreenMode).toBe(true)
94
+
95
+ // 3. raw 출력이 최소한 ANSI 이스케이프를 포함하는지
96
+ expect(output.length).toBeGreaterThan(10)
97
+
98
+ // 3. 'q' 키로 종료
99
+ pty.write("q")
100
+ await Bun.sleep(2000)
101
+
102
+ // 4. 프로세스가 종료되었는지
103
+ // PTY는 kill 후 exit 이벤트가 옴
104
+ const exited = await Promise.race([
105
+ new Promise<boolean>((resolve) => {
106
+ pty!.onExit(() => resolve(true))
107
+ // 이미 종료되었을 수도
108
+ setTimeout(() => resolve(false), 3000)
109
+ }),
110
+ ])
111
+
112
+ // 종료 안 됐으면 강제 kill
113
+ if (!exited) {
114
+ try { pty.kill() } catch {}
115
+ }
116
+ })
117
+
118
+ test("TUI enters alternate screen and responds to quit", async () => {
119
+ const raw = output
120
+ // alternate screen 진입 + ANSI 출력 존재 = 정상 렌더링
121
+ expect(raw.length).toBeGreaterThan(5)
122
+ })
123
+ })
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ // smoke-tui.mjs — SAEEOL TUI 스모크 테스트
3
+ // PTY 환경에서 TUI를 실행하고, 런타임 에러가 있으면 캡처
4
+ import { spawn } from "node:child_process"
5
+ import { writeFileSync, mkdirSync, existsSync } from "node:fs"
6
+ import { join } from "node:path"
7
+
8
+ const TIMEOUT_MS = 8000
9
+ const OUTDIR = join(import.meta.dirname, "..", "..", ".kilo")
10
+ const STDERR_FILE = join(OUTDIR, "tui-smoke-stderr.log")
11
+ const STDOUT_FILE = join(OUTDIR, "tui-smoke-stdout.log")
12
+
13
+ if (!existsSync(OUTDIR)) mkdirSync(OUTDIR, { recursive: true })
14
+
15
+ const args = process.argv.slice(2)
16
+ const binPath = args[0] || join(import.meta.dirname, "..", "..", "bin", "saeeol.cjs")
17
+ const command = args[1] || "tui"
18
+
19
+ console.log(`[smoke] Running: node ${binPath} ${command}`)
20
+ console.log(`[smoke] Timeout: ${TIMEOUT_MS}ms`)
21
+
22
+ const child = spawn("node", [binPath, command], {
23
+ cwd: join(import.meta.dirname, "..", ".."),
24
+ env: { ...process.env, FORCE_COLOR: "0", TERM: "dumb", CI: "true" },
25
+ stdio: ["pipe", "pipe", "pipe"],
26
+ })
27
+
28
+ let stdout = ""
29
+ let stderr = ""
30
+ let killed = false
31
+
32
+ child.stdout.on("data", (d) => { stdout += d })
33
+ child.stderr.on("data", (d) => { stderr += d })
34
+
35
+ const timer = setTimeout(() => {
36
+ killed = true
37
+ child.kill("SIGTERM")
38
+ // SIGTERM 후 2초 대기 후 SIGKILL
39
+ setTimeout(() => {
40
+ if (!child.killed) child.kill("SIGKILL")
41
+ }, 2000)
42
+ }, TIMEOUT_MS)
43
+
44
+ const exitCode = await new Promise((resolve) => {
45
+ child.on("exit", (code) => {
46
+ clearTimeout(timer)
47
+ resolve(code ?? -1)
48
+ })
49
+ })
50
+
51
+ writeFileSync(STDERR_FILE, stderr)
52
+ writeFileSync(STDOUT_FILE, stdout)
53
+
54
+ // 결과 분석
55
+ const errors = stderr.split("\n").filter((l) =>
56
+ /error|Error|ERROR|panic|PANIC|unhandled|Unhandled|REJECTION|rejection/i.test(l)
57
+ && !/warn|info|debug/i.test(l.split(":")[0] || "")
58
+ )
59
+
60
+ const result = {
61
+ exitCode,
62
+ killed,
63
+ timeout: TIMEOUT_MS,
64
+ stderrLines: stderr.split("\n").length,
65
+ stdoutLines: stdout.split("\n").length,
66
+ errorCount: errors.length,
67
+ errors: errors.slice(0, 20),
68
+ files: { stderr: STDERR_FILE, stdout: STDOUT_FILE },
69
+ }
70
+
71
+ console.log("\n[smoke] Result:")
72
+ console.log(JSON.stringify(result, null, 2))
73
+
74
+ if (errors.length > 0) {
75
+ console.error(`\n[smoke] FAIL: ${errors.length} runtime error(s) detected`)
76
+ process.exit(1)
77
+ } else if (!killed && exitCode !== 0) {
78
+ console.error(`\n[smoke] FAIL: TUI exited with code ${exitCode}`)
79
+ process.exit(1)
80
+ } else {
81
+ console.log("\n[smoke] PASS: TUI ran without runtime errors")
82
+ process.exit(0)
83
+ }
@@ -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