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 +3 -1
- package/bunfig.toml +7 -0
- package/package.json +1 -1
- package/src/cli/cmd/tui/preflight.ts +138 -0
- package/src/cli/cmd/tui/thread.ts +20 -0
- package/test/smoke/.tui-debug-output.txt +1 -0
- package/test/smoke/.tui-debug-plain.txt +1 -0
- package/test/smoke/.tui-walkthrough-report.txt +122 -0
- package/test/smoke/smoke-tui-pty.test.ts +123 -0
- package/test/smoke/smoke-tui.mjs +83 -0
- package/test/smoke/tui-walkthrough.test.ts +520 -0
- package/.turbo/turbo-typecheck.log +0 -1
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",
|
|
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
|
@@ -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[2J[m[H]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
|