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.
- 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/src/server/routes/instance/httpapi/groups/ltm.ts +93 -0
- package/src/server/routes/instance/httpapi/handlers/ltm.ts +118 -0
- package/src/server/routes/instance/index.ts +96 -1
- package/test/smoke/.tui-debug-output.txt +1 -0
- package/test/smoke/.tui-debug-plain.txt +1 -0
- package/test/smoke/.tui-input-report.txt +110 -0
- package/test/smoke/.tui-leader-report.txt +70 -0
- package/test/smoke/.tui-scroll-report.txt +66 -0
- package/test/smoke/.tui-slash-report.txt +146 -0
- package/test/smoke/.tui-system-report.txt +62 -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-driver.ts +232 -0
- package/test/smoke/tui-walkthrough-input.test.ts +286 -0
- package/test/smoke/tui-walkthrough-leader.test.ts +175 -0
- package/test/smoke/tui-walkthrough-scroll.test.ts +177 -0
- package/test/smoke/tui-walkthrough-slash.test.ts +302 -0
- package/test/smoke/tui-walkthrough-system.test.ts +210 -0
- package/test/smoke/tui-walkthrough.test.ts +520 -0
- package/.turbo/turbo-typecheck.log +0 -1
|
@@ -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,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tui-walkthrough-driver.ts — 공유 TUI PTY 드라이버
|
|
3
|
+
*
|
|
4
|
+
* 모든 walkthrough 테스트가 사용하는 공통 유틸리티.
|
|
5
|
+
* bun-pty 기반 TUI 제어 + 에러 감지.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn as PtySpawn, type IPty } from "bun-pty"
|
|
9
|
+
import path from "path"
|
|
10
|
+
|
|
11
|
+
const BIN = path.resolve(import.meta.dir, "../../bin/saeeol.cjs")
|
|
12
|
+
|
|
13
|
+
// ── ANSI 제거 ──
|
|
14
|
+
|
|
15
|
+
export function stripAnsi(s: string): string {
|
|
16
|
+
return s
|
|
17
|
+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
|
|
18
|
+
.replace(/\x1b\].*?\x07/g, "")
|
|
19
|
+
.replace(/\x1b\[.*?m/g, "")
|
|
20
|
+
.replace(/[\x00-\x09\x0b\x0c\x0e-\x1f]/g, "")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Known issues — 해결되면 배열에서 제거 ──
|
|
24
|
+
|
|
25
|
+
const KNOWN_ISSUES = [
|
|
26
|
+
/export named 'jsx' not found in module.*@opentui\/solid\/jsx-runtime/i,
|
|
27
|
+
/e=Export named 'jsx' not found.*exception/i,
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export function hasError(plain: string): string[] {
|
|
31
|
+
return plain
|
|
32
|
+
.split("\n")
|
|
33
|
+
.filter((l) => {
|
|
34
|
+
const t = l.trim()
|
|
35
|
+
if (!t) return false
|
|
36
|
+
if (KNOWN_ISSUES.some((re) => re.test(t))) return false
|
|
37
|
+
return (
|
|
38
|
+
/unhandled|TypeError|ReferenceError|SyntaxError|Cannot find module|Error:/i.test(t) ||
|
|
39
|
+
/exception/i.test(t) ||
|
|
40
|
+
/\be=\b.*not found/i.test(t) ||
|
|
41
|
+
/crashed|fatal|panic/i.test(t)
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── PTY 키 시퀀스 ──
|
|
47
|
+
|
|
48
|
+
export const Key = {
|
|
49
|
+
// 제어 문자
|
|
50
|
+
ctrl: (ch: string) => {
|
|
51
|
+
const code = ch.charCodeAt(0) - 96
|
|
52
|
+
return String.fromCharCode(code < 0 ? code + 64 : code)
|
|
53
|
+
},
|
|
54
|
+
esc: "\x1b",
|
|
55
|
+
enter: "\r",
|
|
56
|
+
tab: "\t",
|
|
57
|
+
backspace: "\x7f",
|
|
58
|
+
delete: "\x1b[3~",
|
|
59
|
+
// 화살표
|
|
60
|
+
up: "\x1b[A",
|
|
61
|
+
down: "\x1b[B",
|
|
62
|
+
right: "\x1b[C",
|
|
63
|
+
left: "\x1b[D",
|
|
64
|
+
// 기능키
|
|
65
|
+
f1: "\x1bOP",
|
|
66
|
+
f2: "\x1bOQ",
|
|
67
|
+
f3: "\x1bOR",
|
|
68
|
+
f4: "\x1bOS",
|
|
69
|
+
// 네비게이션
|
|
70
|
+
home: "\x1b[H",
|
|
71
|
+
end: "\x1b[F",
|
|
72
|
+
pageUp: "\x1b[5~",
|
|
73
|
+
pageDown: "\x1b[6~",
|
|
74
|
+
// 수정자 조합
|
|
75
|
+
ctrlUp: "\x1b[1;5A",
|
|
76
|
+
ctrlDown: "\x1b[1;5B",
|
|
77
|
+
ctrlRight: "\x1b[1;5C",
|
|
78
|
+
ctrlLeft: "\x1b[1;5D",
|
|
79
|
+
shiftUp: "\x1b[1;2A",
|
|
80
|
+
shiftDown: "\x1b[1;2B",
|
|
81
|
+
shiftRight: "\x1b[1;2C",
|
|
82
|
+
shiftLeft: "\x1b[1;2D",
|
|
83
|
+
shiftTab: "\x1b[Z",
|
|
84
|
+
altA: "\x1ba",
|
|
85
|
+
altE: "\x1be",
|
|
86
|
+
altF: "\x1bf",
|
|
87
|
+
altB: "\x1bb",
|
|
88
|
+
altD: "\x1bd",
|
|
89
|
+
// ctrl+조합
|
|
90
|
+
ctrlA: "\x01",
|
|
91
|
+
ctrlB: "\x02",
|
|
92
|
+
ctrlC: "\x03",
|
|
93
|
+
ctrlD: "\x04",
|
|
94
|
+
ctrlE: "\x05",
|
|
95
|
+
ctrlF: "\x06",
|
|
96
|
+
ctrlG: "\x07",
|
|
97
|
+
ctrlK: "\x0b",
|
|
98
|
+
ctrlN: "\x0e",
|
|
99
|
+
ctrlP: "\x10",
|
|
100
|
+
ctrlR: "\x12",
|
|
101
|
+
ctrlT: "\x14",
|
|
102
|
+
ctrlU: "\x15",
|
|
103
|
+
ctrlV: "\x16",
|
|
104
|
+
ctrlW: "\x17",
|
|
105
|
+
ctrlX: "\x18", // leader
|
|
106
|
+
ctrlZ: "\x1a",
|
|
107
|
+
// ctrl+shift
|
|
108
|
+
ctrlShiftA: "\x1b[1;6A", // 실제로는 다를 수 있음 — 터미널에 따라 다름
|
|
109
|
+
ctrlShiftD: "\x1b[1;6D",
|
|
110
|
+
ctrlShiftE: "\x1b[1;6E",
|
|
111
|
+
ctrlPageUp: "\x1b[5;5~",
|
|
112
|
+
ctrlPageDown: "\x1b[6;5~",
|
|
113
|
+
shiftF2: "\x1b[1;2Q",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── TUI 드라이버 ──
|
|
117
|
+
|
|
118
|
+
export class TuiDriver {
|
|
119
|
+
pty: IPty
|
|
120
|
+
output = ""
|
|
121
|
+
snapshots: Array<{ name: string; raw: string; plain: string }> = []
|
|
122
|
+
private exited = false
|
|
123
|
+
|
|
124
|
+
constructor(cwd: string, env?: Record<string, string>) {
|
|
125
|
+
this.pty = PtySpawn("node", [BIN, "tui"], {
|
|
126
|
+
name: "xterm-256color",
|
|
127
|
+
cols: 120,
|
|
128
|
+
rows: 30,
|
|
129
|
+
cwd,
|
|
130
|
+
env: {
|
|
131
|
+
...process.env,
|
|
132
|
+
TERM: "xterm-256color",
|
|
133
|
+
FORCE_COLOR: "1",
|
|
134
|
+
SAEEOL_LLM_PROVIDER: "custom",
|
|
135
|
+
SAEEOL_LLM_BASE_URL: "http://localhost:1",
|
|
136
|
+
SAEEOL_LLM_API_KEY: "test",
|
|
137
|
+
SAEEOL_LLM_MODEL: "test",
|
|
138
|
+
...env,
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
this.pty.onData((data) => {
|
|
142
|
+
this.output += data
|
|
143
|
+
})
|
|
144
|
+
this.pty.onExit(() => {
|
|
145
|
+
this.exited = true
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
write(s: string) {
|
|
150
|
+
this.pty.write(s)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async wait(ms: number) {
|
|
154
|
+
await Bun.sleep(ms)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
snapshot(name: string) {
|
|
158
|
+
const raw = this.output
|
|
159
|
+
const plain = stripAnsi(this.output)
|
|
160
|
+
this.snapshots.push({ name, raw, plain })
|
|
161
|
+
return { raw, plain }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
errors(): string[] {
|
|
165
|
+
return hasError(stripAnsi(this.output))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 마지막 스냅샷 이후 새로 발생한 에러만 */
|
|
169
|
+
newErrors(): string[] {
|
|
170
|
+
const prev = this.snapshots.length >= 2
|
|
171
|
+
? stripAnsi(this.snapshots[this.snapshots.length - 2].raw)
|
|
172
|
+
: ""
|
|
173
|
+
const curr = stripAnsi(this.output)
|
|
174
|
+
if (!prev) return hasError(curr)
|
|
175
|
+
// prev에 없던 에러 라인만
|
|
176
|
+
const prevSet = new Set(prev.split("\n").map((l) => l.trim()))
|
|
177
|
+
return hasError(curr).filter((l) => !prevSet.has(l.trim()))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async kill() {
|
|
181
|
+
try {
|
|
182
|
+
this.pty.kill()
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
get isExited() {
|
|
187
|
+
return this.exited
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 특정 텍스트가 화면에 나타날 때까지 대기 */
|
|
191
|
+
async waitFor(text: string, timeoutMs = 10000): Promise<boolean> {
|
|
192
|
+
const start = Date.now()
|
|
193
|
+
while (Date.now() - start < timeoutMs) {
|
|
194
|
+
if (stripAnsi(this.output).includes(text)) return true
|
|
195
|
+
await Bun.sleep(200)
|
|
196
|
+
}
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** leader 키 (ctrl+x) + 후속 키 */
|
|
201
|
+
async leader(key: string, delay = 100) {
|
|
202
|
+
this.write(Key.ctrlX)
|
|
203
|
+
await this.wait(delay)
|
|
204
|
+
this.write(key)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 에스케이프로 다이얼로그 닫기 */
|
|
208
|
+
async dismiss(delay = 500) {
|
|
209
|
+
this.write(Key.esc)
|
|
210
|
+
await this.wait(delay)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 슬래시 명령어 실행 */
|
|
214
|
+
async slash(cmd: string, delay = 500) {
|
|
215
|
+
this.write(`/${cmd}`)
|
|
216
|
+
await this.wait(delay)
|
|
217
|
+
this.write(Key.enter)
|
|
218
|
+
await this.wait(1500)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 스냅샷 리포트 저장 */
|
|
222
|
+
async saveReport(filename: string) {
|
|
223
|
+
const reportPath = path.join(import.meta.dir, filename)
|
|
224
|
+
const report = this.snapshots
|
|
225
|
+
.map(
|
|
226
|
+
(s) =>
|
|
227
|
+
`=== ${s.name} (${s.raw.length} bytes) ===\n${s.plain.slice(-500)}`,
|
|
228
|
+
)
|
|
229
|
+
.join("\n\n")
|
|
230
|
+
await Bun.write(reportPath, report)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tui-walkthrough-input.test.ts — 입력 편집 키바인드 전체 검증
|
|
3
|
+
*
|
|
4
|
+
* 프롬프트 textarea의 모든 편집 단축키를 한 번씩 누름:
|
|
5
|
+
* - 커서 이동: left, right, up, down, home, end
|
|
6
|
+
* - ctrl+커서: ctrl+a (line home), ctrl+e (line end), ctrl+b (left), ctrl+f (right)
|
|
7
|
+
* - 단어 이동: alt+f, alt+b, ctrl+left, ctrl+right
|
|
8
|
+
* - 선택: shift+방향키, ctrl+shift+a/e
|
|
9
|
+
* - 삭제: backspace, delete, ctrl+k, ctrl+u, ctrl+w, ctrl+d
|
|
10
|
+
* - 단어 삭제: alt+d, ctrl+backspace
|
|
11
|
+
* - 클리어: ctrl+c
|
|
12
|
+
* - 입력: 일반 텍스트, enter, newline(shift+enter)
|
|
13
|
+
* - undo/redo: ctrl+-, ctrl+z (win32), ctrl+.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test"
|
|
17
|
+
import { tmpdir, disposeAllInstances } from "../fixture/fixture"
|
|
18
|
+
import * as Log from "@saeeol/core/util/log"
|
|
19
|
+
import { Flag } from "@saeeol/core/flag/flag"
|
|
20
|
+
import { TuiDriver, Key } from "./tui-walkthrough-driver"
|
|
21
|
+
|
|
22
|
+
void Log.init({ print: false })
|
|
23
|
+
|
|
24
|
+
describe("TUI walkthrough: input editing", () => {
|
|
25
|
+
let driver: TuiDriver
|
|
26
|
+
let tmp: Awaited<ReturnType<typeof tmpdir>>
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
tmp = await tmpdir({ git: true, config: {} })
|
|
30
|
+
Flag.SAEEOL_EXPERIMENTAL_HTTPAPI = false
|
|
31
|
+
driver = new TuiDriver(tmp.path)
|
|
32
|
+
await driver.wait(6000)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterAll(async () => {
|
|
36
|
+
await driver.saveReport(".tui-input-report.txt")
|
|
37
|
+
await driver.kill()
|
|
38
|
+
await disposeAllInstances()
|
|
39
|
+
Flag.SAEEOL_EXPERIMENTAL_HTTPAPI = true
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// ── 기본 입력 ──
|
|
43
|
+
|
|
44
|
+
test("typing: regular text input", async () => {
|
|
45
|
+
driver.write("hello world this is a test")
|
|
46
|
+
await driver.wait(500)
|
|
47
|
+
driver.snapshot("input-typed")
|
|
48
|
+
expect(driver.errors()).toHaveLength(0)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// ── 커서 이동 ──
|
|
52
|
+
|
|
53
|
+
test("cursor: left/right arrow", async () => {
|
|
54
|
+
driver.write(Key.left)
|
|
55
|
+
await driver.wait(100)
|
|
56
|
+
driver.write(Key.left)
|
|
57
|
+
await driver.wait(100)
|
|
58
|
+
driver.write(Key.right)
|
|
59
|
+
await driver.wait(100)
|
|
60
|
+
driver.snapshot("input-cursor-lr")
|
|
61
|
+
expect(driver.errors()).toHaveLength(0)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("cursor: up/down arrow", async () => {
|
|
65
|
+
driver.write(Key.up)
|
|
66
|
+
await driver.wait(100)
|
|
67
|
+
driver.write(Key.down)
|
|
68
|
+
await driver.wait(100)
|
|
69
|
+
driver.snapshot("input-cursor-ud")
|
|
70
|
+
expect(driver.errors()).toHaveLength(0)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test("cursor: home/end", async () => {
|
|
74
|
+
driver.write(Key.home)
|
|
75
|
+
await driver.wait(100)
|
|
76
|
+
driver.write(Key.end)
|
|
77
|
+
await driver.wait(100)
|
|
78
|
+
driver.snapshot("input-cursor-home-end")
|
|
79
|
+
expect(driver.errors()).toHaveLength(0)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test("cursor: ctrl+a (line home)", async () => {
|
|
83
|
+
driver.write(Key.ctrlA)
|
|
84
|
+
await driver.wait(200)
|
|
85
|
+
driver.snapshot("input-ctrl-a")
|
|
86
|
+
expect(driver.errors()).toHaveLength(0)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("cursor: ctrl+e (line end)", async () => {
|
|
90
|
+
driver.write(Key.ctrlE)
|
|
91
|
+
await driver.wait(200)
|
|
92
|
+
driver.snapshot("input-ctrl-e")
|
|
93
|
+
expect(driver.errors()).toHaveLength(0)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test("cursor: ctrl+b (left)", async () => {
|
|
97
|
+
driver.write(Key.ctrlB)
|
|
98
|
+
await driver.wait(100)
|
|
99
|
+
driver.write(Key.ctrlB)
|
|
100
|
+
await driver.wait(100)
|
|
101
|
+
driver.snapshot("input-ctrl-b")
|
|
102
|
+
expect(driver.errors()).toHaveLength(0)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("cursor: ctrl+f (right)", async () => {
|
|
106
|
+
driver.write(Key.ctrlF)
|
|
107
|
+
await driver.wait(100)
|
|
108
|
+
driver.write(Key.ctrlF)
|
|
109
|
+
await driver.wait(100)
|
|
110
|
+
driver.snapshot("input-ctrl-f")
|
|
111
|
+
expect(driver.errors()).toHaveLength(0)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// ── 단어 이동 ──
|
|
115
|
+
|
|
116
|
+
test("word: alt+f (forward)", async () => {
|
|
117
|
+
driver.write(Key.altF)
|
|
118
|
+
await driver.wait(200)
|
|
119
|
+
driver.snapshot("input-alt-f")
|
|
120
|
+
expect(driver.errors()).toHaveLength(0)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test("word: alt+b (backward)", async () => {
|
|
124
|
+
driver.write(Key.altB)
|
|
125
|
+
await driver.wait(200)
|
|
126
|
+
driver.snapshot("input-alt-b")
|
|
127
|
+
expect(driver.errors()).toHaveLength(0)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test("word: ctrl+left", async () => {
|
|
131
|
+
driver.write(Key.ctrlLeft)
|
|
132
|
+
await driver.wait(200)
|
|
133
|
+
driver.snapshot("input-ctrl-left")
|
|
134
|
+
expect(driver.errors()).toHaveLength(0)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test("word: ctrl+right", async () => {
|
|
138
|
+
driver.write(Key.ctrlRight)
|
|
139
|
+
await driver.wait(200)
|
|
140
|
+
driver.snapshot("input-ctrl-right")
|
|
141
|
+
expect(driver.errors()).toHaveLength(0)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// ── 선택 ──
|
|
145
|
+
|
|
146
|
+
test("select: shift+left/right", async () => {
|
|
147
|
+
driver.write(Key.shiftLeft)
|
|
148
|
+
await driver.wait(100)
|
|
149
|
+
driver.write(Key.shiftLeft)
|
|
150
|
+
await driver.wait(100)
|
|
151
|
+
driver.write(Key.shiftRight)
|
|
152
|
+
await driver.wait(100)
|
|
153
|
+
driver.snapshot("input-select-lr")
|
|
154
|
+
expect(driver.errors()).toHaveLength(0)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test("select: shift+up/down", async () => {
|
|
158
|
+
driver.write(Key.shiftUp)
|
|
159
|
+
await driver.wait(100)
|
|
160
|
+
driver.write(Key.shiftDown)
|
|
161
|
+
await driver.wait(100)
|
|
162
|
+
driver.snapshot("input-select-ud")
|
|
163
|
+
expect(driver.errors()).toHaveLength(0)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test("select: alt+a (visual line home)", async () => {
|
|
167
|
+
driver.write(Key.altA)
|
|
168
|
+
await driver.wait(200)
|
|
169
|
+
driver.snapshot("input-alt-a")
|
|
170
|
+
expect(driver.errors()).toHaveLength(0)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test("select: alt+e (visual line end)", async () => {
|
|
174
|
+
driver.write(Key.altE)
|
|
175
|
+
await driver.wait(200)
|
|
176
|
+
driver.snapshot("input-alt-e")
|
|
177
|
+
expect(driver.errors()).toHaveLength(0)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// ── 삭제 ──
|
|
181
|
+
|
|
182
|
+
test("delete: backspace", async () => {
|
|
183
|
+
driver.write(Key.backspace)
|
|
184
|
+
await driver.wait(200)
|
|
185
|
+
driver.snapshot("input-backspace")
|
|
186
|
+
expect(driver.errors()).toHaveLength(0)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test("delete: delete key", async () => {
|
|
190
|
+
driver.write(Key.delete)
|
|
191
|
+
await driver.wait(200)
|
|
192
|
+
driver.snapshot("input-delete")
|
|
193
|
+
expect(driver.errors()).toHaveLength(0)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test("delete: ctrl+k (to line end)", async () => {
|
|
197
|
+
driver.write(Key.ctrlK)
|
|
198
|
+
await driver.wait(200)
|
|
199
|
+
driver.snapshot("input-ctrl-k")
|
|
200
|
+
expect(driver.errors()).toHaveLength(0)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test("delete: ctrl+u (to line start)", async () => {
|
|
204
|
+
driver.write(Key.ctrlU)
|
|
205
|
+
await driver.wait(200)
|
|
206
|
+
driver.snapshot("input-ctrl-u")
|
|
207
|
+
expect(driver.errors()).toHaveLength(0)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test("delete: ctrl+w (word backward)", async () => {
|
|
211
|
+
// 텍스트 다시 입력
|
|
212
|
+
driver.write("another test phrase")
|
|
213
|
+
await driver.wait(200)
|
|
214
|
+
driver.write(Key.ctrlW)
|
|
215
|
+
await driver.wait(200)
|
|
216
|
+
driver.snapshot("input-ctrl-w")
|
|
217
|
+
expect(driver.errors()).toHaveLength(0)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test("delete: ctrl+d (char delete)", async () => {
|
|
221
|
+
driver.write(Key.ctrlD)
|
|
222
|
+
await driver.wait(200)
|
|
223
|
+
driver.snapshot("input-ctrl-d")
|
|
224
|
+
expect(driver.errors()).toHaveLength(0)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test("delete: alt+d (word forward)", async () => {
|
|
228
|
+
driver.write("word1 word2 word3")
|
|
229
|
+
await driver.wait(200)
|
|
230
|
+
driver.write(Key.altD)
|
|
231
|
+
await driver.wait(200)
|
|
232
|
+
driver.snapshot("input-alt-d")
|
|
233
|
+
expect(driver.errors()).toHaveLength(0)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// ── 클리어 / 언두 ──
|
|
237
|
+
|
|
238
|
+
test("clear: ctrl+c clears input", async () => {
|
|
239
|
+
driver.write("should be cleared")
|
|
240
|
+
await driver.wait(200)
|
|
241
|
+
driver.write(Key.ctrlC)
|
|
242
|
+
await driver.wait(300)
|
|
243
|
+
driver.snapshot("input-clear-ctrl-c")
|
|
244
|
+
expect(driver.errors()).toHaveLength(0)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test("undo: ctrl+-", async () => {
|
|
248
|
+
driver.write("typed text")
|
|
249
|
+
await driver.wait(200)
|
|
250
|
+
// ctrl+- 는 터미널에서 \x1f
|
|
251
|
+
driver.write("\x1f")
|
|
252
|
+
await driver.wait(300)
|
|
253
|
+
driver.snapshot("input-undo-ctrl-minus")
|
|
254
|
+
expect(driver.errors()).toHaveLength(0)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test("redo: ctrl+.", async () => {
|
|
258
|
+
driver.write(Key.ctrlV) // ctrl+. — 재할당 불가하므로 대략적
|
|
259
|
+
await driver.wait(200)
|
|
260
|
+
driver.snapshot("input-redo")
|
|
261
|
+
expect(driver.errors()).toHaveLength(0)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
// ── 새 텍스트 입력 후 엔터 ──
|
|
265
|
+
|
|
266
|
+
test("submit: enter key (does not crash)", async () => {
|
|
267
|
+
driver.write(Key.ctrlC) // 클리어
|
|
268
|
+
await driver.wait(200)
|
|
269
|
+
driver.write("test message")
|
|
270
|
+
await driver.wait(200)
|
|
271
|
+
// 엔터 — 실제 전송은 안 됨 (LLM 서버 죽음), 크래시만 안 하면 됨
|
|
272
|
+
driver.write(Key.enter)
|
|
273
|
+
await driver.wait(1000)
|
|
274
|
+
driver.snapshot("input-submit")
|
|
275
|
+
expect(driver.errors()).toHaveLength(0)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
// ── 정리 ──
|
|
279
|
+
|
|
280
|
+
test("cleanup: clear and verify", async () => {
|
|
281
|
+
driver.write(Key.ctrlC)
|
|
282
|
+
await driver.wait(300)
|
|
283
|
+
driver.snapshot("input-cleanup")
|
|
284
|
+
expect(driver.errors()).toHaveLength(0)
|
|
285
|
+
})
|
|
286
|
+
})
|