@mobius-os/mobius 0.3.26 → 0.3.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,889 @@
1
+ /**
2
+ * UI tests — drive the Ink screens with ink-testing-library against a mocked
3
+ * fetch (REST + a fake SSE stream), so no network is needed. The Mobius home
4
+ * dir is redirected to a temp folder so the real ~/.mobius is never touched.
5
+ *
6
+ * Run: npm run test:ui
7
+ */
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+ import fs from 'node:fs'
11
+
12
+ // Redirect Mobius home BEFORE importing anything that reads/writes it.
13
+ const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'mobius-tui-test-'))
14
+ process.env.MOBIUS_TUI_HOME = TMP_HOME
15
+
16
+ import React from 'react'
17
+ import { render } from 'ink-testing-library'
18
+ import { ChatScreen, Composer, shimmerText } from '../src/components/Chat.js'
19
+ import { WindowsInputDecoder } from '../src/lib/windows-input.js'
20
+ import { LoginScreen } from '../src/components/Login.js'
21
+ import { PrepScreen } from '../src/components/PrepScreen.js'
22
+ import { Select, TextInput } from '../src/components/primitives.js'
23
+ import { MobiusClient } from '../src/api.js'
24
+ import { renderMarkdownLines } from '../src/markdown.js'
25
+ import { viewsForEntry, toolLabel } from '../src/lib/entry-view.js'
26
+ import { SseConnection } from '../src/sse.js'
27
+ import type { ReadyState } from '../src/components/PrepScreen.js'
28
+
29
+ const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
30
+ let pass = 0, fail = 0
31
+ function ok(c: boolean, msg: string) {
32
+ if (c) { pass++; console.log(` ✓ ${msg}`) } else { fail++; console.error(` ✗ ${msg}`) }
33
+ }
34
+
35
+ async function waitFor(lastFrame: () => string | undefined, needle: string, timeoutMs = 4000): Promise<boolean> {
36
+ for (let i = 0; i < Math.ceil(timeoutMs / 50); i++) {
37
+ if ((lastFrame() ?? '').includes(needle)) return true
38
+ await delay(50)
39
+ }
40
+ return false
41
+ }
42
+
43
+ // ── shared mock state for the fake SSE controller ─────────────────────────────
44
+ // Node 18 exposes ReadableStream as a global at runtime; use globalThis + loose typing.
45
+ const RS: any = (globalThis as any).ReadableStream
46
+ let sseController: any = null
47
+ const enc = new TextEncoder()
48
+ function emit(eventName: string, data: Record<string, unknown>) {
49
+ const payload = JSON.stringify({ event: eventName, ...data })
50
+ sseController?.enqueue(enc.encode(`event: ${eventName}\ndata: ${payload}\n\n`))
51
+ }
52
+
53
+ function jsonResponse(body: unknown, status = 200): Response {
54
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
55
+ }
56
+
57
+ type FetchImpl = typeof fetch
58
+ let realFetch: FetchImpl
59
+
60
+ function installMock(impl: (url: string, init?: RequestInit) => Response | Promise<Response>) {
61
+ realFetch = globalThis.fetch
62
+ globalThis.fetch = ((url: any, init?: any) => {
63
+ const requestUrl = String(url)
64
+ // Fresh TUI sessions probe the reverse AIMUX bridge before creating a
65
+ // session. Keep UI tests focused on their own mocked endpoint instead of
66
+ // waiting through the production 8-second bridge readiness grace period.
67
+ const marker = '/aimux_bridge/api/remotes/'
68
+ if (requestUrl.includes(marker) && requestUrl.endsWith('/connection')) {
69
+ const identifier = decodeURIComponent(requestUrl.slice(requestUrl.indexOf(marker) + marker.length, -'/connection'.length))
70
+ return jsonResponse({ identifier, event_stream_connected: true })
71
+ }
72
+ return impl(requestUrl, init)
73
+ }) as FetchImpl
74
+ }
75
+ function restoreFetch() { globalThis.fetch = realFetch }
76
+
77
+ // ════════════════════════════════════════════════════════════════════════════
78
+ // TEST 1 — Login screen submits and calls onSuccess
79
+ // ════════════════════════════════════════════════════════════════════════════
80
+ async function testLogin() {
81
+ console.log('\n[UI 1] Login screen')
82
+ // (a) deterministic: exercise the real login() + saveLogin() code path.
83
+ installMock((url) => {
84
+ if (url.endsWith('/api/auth/config')) return jsonResponse({ password_required: false })
85
+ if (url.endsWith('/api/auth/login')) return jsonResponse({ token: 'mock-jwt-token', user: { id: 'tester', display_name: 'Test User', role: 'admin' } })
86
+ return jsonResponse({ error: 'no mock' }, 404)
87
+ })
88
+ try {
89
+ const { login } = await import('../src/api.js')
90
+ const { saveLogin } = await import('../src/config.js')
91
+ const r = await login('http://mock.local', 'tester')
92
+ ok(r.token === 'mock-jwt-token' && r.user.id === 'tester', 'login() returns token + user')
93
+ await saveLogin({ server: 'http://mock.local', username: 'tester', token: r.token, user: r.user })
94
+ const saved = JSON.parse(fs.readFileSync(path.join(TMP_HOME, 'login.json'), 'utf8'))
95
+ ok(saved.token === 'mock-jwt-token' && saved.username === 'tester', 'login.json persisted to temp home')
96
+ } finally { restoreFetch() }
97
+
98
+ // (b) smoke: the form renders (keystroke-driven multi-field submit is flaky in
99
+ // the test harness due to useInput/rerender timing; the submit handler
100
+ // itself is covered by the deterministic login() path above).
101
+ let captured: any = null
102
+ installMock((url) => {
103
+ if (url.endsWith('/api/auth/config')) return jsonResponse({ password_required: false })
104
+ if (url.endsWith('/api/auth/login')) return jsonResponse({ token: 'mock-jwt-token', user: { id: 'tester', display_name: 'Test User', role: 'admin' } })
105
+ return jsonResponse({ error: 'no mock' }, 404)
106
+ })
107
+ try {
108
+ const { stdin, lastFrame, unmount } = render(<LoginScreen onSuccess={(r) => { captured = r }} />)
109
+ await delay(60)
110
+ const frame = lastFrame() ?? ''
111
+ ok(frame.includes('登录') && frame.includes('用户名'), 'login form renders with fields')
112
+ // best-effort keystroke submit; assert only if the harness lands the keys.
113
+ stdin.write('\t'); await delay(120)
114
+ stdin.write('tester'); await delay(120)
115
+ stdin.write('\t'); await delay(120)
116
+ stdin.write('\r')
117
+ for (let i = 0; i < 50 && !captured; i++) await delay(25)
118
+ unmount()
119
+ console.log(` ${captured ? '✓' : '·'} form keystroke submit ${captured ? 'succeeded' : 'skipped (harness timing)'} — logic covered by (a)`)
120
+ } finally { restoreFetch() }
121
+ }
122
+
123
+ // ════════════════════════════════════════════════════════════════════════════
124
+ // TEST 2 — Chat screen: submit message, SSE streams assistant reply
125
+ // ════════════════════════════════════════════════════════════════════════════
126
+ async function testChat() {
127
+ console.log('\n[UI 2] Chat screen + SSE streaming')
128
+ sseController = null
129
+ const client = new MobiusClient('http://mock.local', 'mock-jwt-token')
130
+ const ready: ReadyState = {
131
+ project: { id: 'p1', name: '测试项目' },
132
+ issue: { id: 'i1', project_id: 'p1', title: '测试任务' },
133
+ prefs: { model: 'codex', language: 'zh', excluded_skill_ids: [], excluded_memory_ids: [] },
134
+ }
135
+ let runtimeWorking = false
136
+ let createdSessionBody: any = null
137
+ installMock((url, init) => {
138
+ if (url.includes('/events')) {
139
+ const stream = new RS({
140
+ start(c: any) { sseController = c; c.enqueue(enc.encode('event: subscribed\ndata: {"event":"subscribed","session":{}}\n\n')) },
141
+ })
142
+ return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } })
143
+ }
144
+ if (url.endsWith('/messages') && init?.method === 'POST') {
145
+ // emit a scripted reply shortly after the message is posted
146
+ setTimeout(() => {
147
+ emit('typing', { active: true })
148
+ emit('jsonl_entry', { session_id: 's1', entry: { type: 'user', message: { role: 'user', content: '你好' } } })
149
+ emit('jsonl_entry', { session_id: 's1', entry: { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: '成功!\n\n```typescript\nconst answer = 42\nconsole.log(answer)\n```' }] } } })
150
+ emit('typing', { active: false })
151
+ }, 250)
152
+ return jsonResponse({ ok: true, session_id: 's1', turn_number: 1 })
153
+ }
154
+ if (url.endsWith('/api/sessions/s1/status')) {
155
+ return jsonResponse({ session_id: 's1', alive: true, working: runtimeWorking })
156
+ }
157
+ if (url.includes('/sessions') && init?.method === 'POST') {
158
+ createdSessionBody = JSON.parse(String(init.body || '{}'))
159
+ return jsonResponse({ session_id: 's1' })
160
+ }
161
+ return jsonResponse({ error: 'no mock' }, 404)
162
+ })
163
+ try {
164
+ const { stdin, lastFrame, unmount } = render(
165
+ <ChatScreen client={client} ready={ready} webUserId="test-user" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />
166
+ )
167
+ await delay(40)
168
+ const initialFrame = lastFrame() ?? ''
169
+ ok(initialFrame.includes('Mobius') && /\(v\d+\.\d+\.\d+\)/.test(initialFrame) && !initialFrame.includes('Mobius TUI'), 'welcome card shows the Mobius product identity')
170
+ ok(initialFrame.includes('model:') && initialFrame.includes('project:') && initialFrame.includes('task:'), 'welcome card summarizes active context')
171
+ ok(initialFrame.includes('Tip:') && initialFrame.includes('输入问题或 / 命令'), 'welcome tip and bottom composer are visible together')
172
+ ok(initialFrame.includes('http://mock.local/u/test-user/p/p1/i/i1'), 'web issue URL is always visible before session creation')
173
+ stdin.write('你好'); await delay(30)
174
+ stdin.write('\r'); await delay(80)
175
+ ok((lastFrame() ?? '').includes('第一个问题,正在初始化'), 'first query shows 第一个问题 instead of Working immediately after submit')
176
+ runtimeWorking = true
177
+ await delay(820) // createSession → connect → POST → emit
178
+ runtimeWorking = false
179
+ emit('typing', { active: false })
180
+ await delay(100)
181
+ const frame = lastFrame() ?? ''
182
+ unmount()
183
+ ok(frame.includes('你好'), 'transcript shows the user message')
184
+ ok(frame.includes('成功'), `assistant reply streamed in (frame has "成功")`)
185
+ ok(frame.includes('const answer = 42') && frame.includes('console.log(answer)'), 'fenced code renders as clean source lines')
186
+ ok(!frame.includes('[typescript]') && !frame.includes('```'), 'code block omits language badge and fence characters')
187
+ ok(frame.includes('测试项目') && frame.includes('测试任务'), `persistent status shows project and task`)
188
+ ok(frame.includes('http://mock.local/u/test-user/p/p1/i/i1?session=s1'), 'web URL follows the newly created session')
189
+ ok(!frame.includes('Working ('), 'authoritative idle status clears Working after completion')
190
+ ok(createdSessionBody?.pc_client_metadata?.is_tui === true, 'session metadata identifies the TUI client')
191
+ ok(createdSessionBody?.pc_client_metadata?.work_mode === 'pc', 'TUI sessions always default to pc work mode')
192
+ ok(/^tui-/.test(createdSessionBody?.pc_client_metadata?.aimux_id || ''), 'session metadata uses the TUI AIMUX identifier')
193
+ ok(createdSessionBody?.pc_client_metadata?.local_path === process.cwd(), 'session metadata includes the TUI current directory')
194
+ } finally { restoreFetch() }
195
+ }
196
+
197
+ // ════════════════════════════════════════════════════════════════════════════
198
+ // TEST 3 — resumed sessions restore and stop their live Working state
199
+ // ════════════════════════════════════════════════════════════════════════════
200
+ async function testResumedWorkingStatus() {
201
+ console.log('\n[UI 3] Resumed session Working status')
202
+ sseController = null
203
+ let runtimeWorking = true
204
+ let stopped = false
205
+ const client = new MobiusClient('http://mock.local', 'mock-jwt-token')
206
+ const ready: ReadyState = {
207
+ project: { id: 'p1', name: '测试项目' },
208
+ issue: { id: 'i1', project_id: 'p1', title: '测试任务' },
209
+ prefs: { model: 'codex', language: 'zh', excluded_skill_ids: [], excluded_memory_ids: [] },
210
+ }
211
+ installMock((url, init) => {
212
+ if (url.includes('/events')) {
213
+ return new Response(new RS({
214
+ start(c: any) { sseController = c; c.enqueue(enc.encode('event: subscribed\ndata: {"event":"subscribed","session":{}}\n\n')) },
215
+ }), { status: 200, headers: { 'content-type': 'text/event-stream' } })
216
+ }
217
+ if (url.endsWith('/api/sessions/s1/status')) {
218
+ return jsonResponse({ session_id: 's1', alive: true, working: runtimeWorking })
219
+ }
220
+ if (url.endsWith('/api/sessions/s1/stop') && init?.method === 'POST') {
221
+ runtimeWorking = false
222
+ stopped = true
223
+ return jsonResponse({ ok: true })
224
+ }
225
+ return jsonResponse({ error: 'no mock' }, 404)
226
+ })
227
+ try {
228
+ const { stdin, lastFrame, unmount } = render(
229
+ <ChatScreen client={client} ready={ready} webUserId="test-user" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />
230
+ )
231
+ await delay(120)
232
+ ok((lastFrame() ?? '').includes('Working ('), 'resuming an already-running session restores Working without a new typing event')
233
+
234
+ runtimeWorking = false
235
+ emit('typing', { active: false })
236
+ await delay(120)
237
+ ok(!(lastFrame() ?? '').includes('Working ('), 'SSE completion requests an immediate authoritative status refresh')
238
+
239
+ runtimeWorking = true
240
+ emit('typing', { active: true })
241
+ await delay(30)
242
+ ok((lastFrame() ?? '').includes('Working ('), 'SSE start lights Working without waiting for the next scheduled poll')
243
+ stdin.write('\x1b')
244
+ await delay(120)
245
+ ok(stopped && !(lastFrame() ?? '').includes('Working ('), 'Esc stops the active session and clears Working immediately')
246
+ unmount()
247
+ } finally { restoreFetch() }
248
+ }
249
+
250
+ // ════════════════════════════════════════════════════════════════════════════
251
+ // TEST 4 — Markdown follows Codex's borderless, foreground-only code style
252
+ // ════════════════════════════════════════════════════════════════════════════
253
+ function testMarkdownCodeRendering() {
254
+ console.log('\n[UI 4] Markdown code rendering')
255
+ const stripAnsi = (value: string) => value.replace(/\x1B\[[0-9;]*m/g, '')
256
+ const rendered = renderMarkdownLines('说明 `answer`:\n\n```typescript title=demo\nconst answer = 42\n```\n\n完成。')
257
+ const plain = rendered.map(line => stripAnsi(line.text)).join('\n')
258
+ ok(plain.includes('说明 answer:') && !plain.includes('`answer`'), 'inline code uses color without literal backticks')
259
+ ok(plain.includes('const answer = 42') && !plain.includes('[typescript]') && !plain.includes('```'), 'fenced code has no badge, border, or fences')
260
+ ok(rendered.some(line => line.code && stripAnsi(line.text) === 'const answer = 42'), 'code lines are marked for no-wrap rendering')
261
+
262
+ const unlabelled = renderMarkdownLines('```\necho $HOME\n```')
263
+ ok(unlabelled.length === 1 && unlabelled[0].text === 'echo $HOME' && unlabelled[0].code, 'unlabelled code stays plain instead of being guessed as bash')
264
+ }
265
+
266
+ // ════════════════════════════════════════════════════════════════════════════
267
+ // TEST 5 — Prep screen renders the project picker when cwd is unbound
268
+ // ════════════════════════════════════════════════════════════════════════════
269
+ async function testPrepRender() {
270
+ console.log('\n[UI 5] Prep screen project picker')
271
+ const client = new MobiusClient('http://mock.local', 'mock-jwt-token')
272
+ installMock((url) => {
273
+ if (url.includes('/api/projects') && !url.includes('/issues') && !url.includes('/skills') && !url.includes('/memories')) {
274
+ return jsonResponse([
275
+ { id: 'p1', name: '已有项目A', description: '第一行\n第二行' },
276
+ { id: 'p2', name: '已有项目B', description: '单行描述' },
277
+ ])
278
+ }
279
+ if (url.includes('/issues')) return jsonResponse([])
280
+ return jsonResponse({ error: 'no mock' }, 404)
281
+ })
282
+ try {
283
+ const { lastFrame, stdin, unmount } = render(<PrepScreen client={client} onReady={() => {}} />)
284
+ await delay(120)
285
+ const frame = lastFrame() ?? ''
286
+ ok(frame.includes('选择当前路径的绑定项目'), 'project picker title shown')
287
+ ok(frame.includes('已有项目A') && frame.includes('已有项目B'), 'existing projects listed')
288
+ ok(frame.includes('创建新项目'), 'create-new option present')
289
+ // multi-line description must be flattened onto one line with ⏎ in place of \n
290
+ ok(frame.includes('已有项目A — 第一行 ⏎ 第二行'), 'multi-line description flattened to a single line')
291
+ ok(frame.includes('已有项目B — 单行描述'), 'single-line description kept as-is')
292
+ ok(!frame.includes('加载项目列表…'), 'completed project load does not leave a stale loading message')
293
+
294
+ stdin.write('\r')
295
+ await delay(30)
296
+ const createFrame = lastFrame() ?? ''
297
+ ok(createFrame.includes('创建新项目(绑定到当前路径)'), 'project creation form opens')
298
+ ok(/项目名称 ←\n\s+未命名项目/.test(createFrame), 'cursor is rendered on the active project-name input')
299
+ ok(!createFrame.includes('描述(可空)'), 'project description input is hidden')
300
+ ok(createFrame.includes('回车创建 · Esc 返回'), 'project name submits directly with Enter')
301
+ unmount()
302
+ } finally { restoreFetch() }
303
+ }
304
+
305
+ // ════════════════════════════════════════════════════════════════════════════
306
+ // TEST 6 — Select viewport: a long list must not overflow the terminal
307
+ // ════════════════════════════════════════════════════════════════════════════
308
+ async function testSelectViewport() {
309
+ console.log('\n[UI 6] Select viewport truncation')
310
+ const items = Array.from({ length: 12 }, (_, i) => ({ label: `项目${i}`, value: `v${i}` }))
311
+ const { lastFrame, stdin } = render(<Select items={items} maxVisible={3} />)
312
+ await delay(20)
313
+ let frame = lastFrame() ?? ''
314
+ ok(frame.includes('项目0') && frame.includes('项目2'), 'top window: first 3 visible')
315
+ ok(!frame.includes('项目3'), 'top window: item past the window hidden')
316
+ ok(frame.includes('↓ 还有 9 项'), 'top window: hidden-below hint')
317
+ ok(!frame.includes('↑ 还有'), 'top window: no hidden-above hint')
318
+ // walk active into the middle of the list
319
+ for (let i = 0; i < 5; i++) { stdin.write('\x1b[B'); await delay(10) }
320
+ frame = lastFrame() ?? ''
321
+ ok(frame.includes('项目5'), 'middle: active item kept visible')
322
+ ok(frame.includes('↑ 还有') && frame.includes('↓ 还有'), 'middle: both tail hints shown')
323
+ ok(!frame.includes('项目0') && !frame.includes('项目11'), 'middle: far items hidden')
324
+
325
+ // The first navigation key after mounting must not depend on a later render
326
+ // (the regression presented as arrows doing nothing until Enter was pressed).
327
+ const immediate = render(<Select items={[{ label: '首项', value: 'first' }, { label: '次项', value: 'second' }]} />)
328
+ await delay(20)
329
+ immediate.rerender(<Select items={[{ label: '首项', value: 'first' }, { label: '次项', value: 'second' }]} />)
330
+ immediate.stdin.write('\x1b[B')
331
+ await delay(20)
332
+ ok((immediate.lastFrame() ?? '').includes('❯ 次项'), 'first arrow key is handled immediately after Select mounts')
333
+ immediate.unmount()
334
+ }
335
+
336
+ // ════════════════════════════════════════════════════════════════════════════
337
+ // TEST 7 — Project picker: Esc exits the app via onQuit
338
+ // ════════════════════════════════════════════════════════════════════════════
339
+ async function testProjectPickerEscQuit() {
340
+ console.log('\n[UI 7] Project picker Esc → onQuit')
341
+ const client = new MobiusClient('http://mock.local', 'mock-jwt-token')
342
+ installMock((url) => {
343
+ if (url.includes('/api/projects') && !url.includes('/issues') && !url.includes('/skills') && !url.includes('/memories')) {
344
+ return jsonResponse([{ id: 'p1', name: '已有项目A' }, { id: 'p2', name: '已有项目B' }])
345
+ }
346
+ if (url.includes('/issues')) return jsonResponse([])
347
+ return jsonResponse({ error: 'no mock' }, 404)
348
+ })
349
+ let quitCalled = false
350
+ try {
351
+ const { lastFrame, stdin, unmount } = render(<PrepScreen client={client} onReady={() => {}} onQuit={() => { quitCalled = true }} />)
352
+ await delay(120)
353
+ ok((lastFrame() ?? '').includes('Esc 退出'), 'esc-to-quit hint shown')
354
+ stdin.write('\x1b')
355
+ await delay(30)
356
+ unmount()
357
+ ok(quitCalled, 'Esc on the list triggered onQuit')
358
+ } finally { restoreFetch() }
359
+ }
360
+
361
+ // ════════════════════════════════════════════════════════════════════════════
362
+ // TEST 8 — TextInput: terminal Backspace (0x7f) deletes at the end of the input
363
+ // ════════════════════════════════════════════════════════════════════════════
364
+ async function testTextInputBackspace() {
365
+ console.log('\n[UI 8] TextInput Backspace deletes trailing char')
366
+ function Harness() {
367
+ const [v, setV] = React.useState('abc')
368
+ return <TextInput value={v} onChange={setV} focused />
369
+ }
370
+ const { stdin, lastFrame, unmount } = render(<Harness />)
371
+ await delay(20)
372
+ ok((lastFrame() ?? '').includes('abc'), 'initial value rendered')
373
+ stdin.write(String.fromCharCode(127)) // 0x7f — what virtually every terminal's Backspace key emits
374
+ await delay(20)
375
+ const after = lastFrame() ?? ''
376
+ ok(after.includes('ab') && !after.includes('abc'), 'Backspace (0x7f) deleted the trailing char')
377
+ unmount()
378
+ }
379
+
380
+ // ════════════════════════════════════════════════════════════════════════════
381
+ // TEST 8b — TextInput: Delete key (ESC[3~) deletes FORWARD, not backward;
382
+ // Ctrl+Backspace (ESC[3;5~) and Alt+Backspace (ESC DEL) delete the whole word.
383
+ // Ink reports Backspace (\x7f) and Delete ([3~) as the same key.delete, so the
384
+ // raw stdin bytes must drive these.
385
+ // ════════════════════════════════════════════════════════════════════════════
386
+ async function testTextInputDeleteKeys() {
387
+ console.log('\n[UI 8b] TextInput Delete-forward + Ctrl+Backspace word delete')
388
+ function Harness() {
389
+ const [v, setV] = React.useState('abc')
390
+ return <TextInput value={v} onChange={setV} focused />
391
+ }
392
+ const { stdin, lastFrame, unmount } = render(<Harness />)
393
+ await delay(20)
394
+ // Cursor starts at the end; Ctrl+A moves it to position 0.
395
+ stdin.write('\x01')
396
+ await delay(10)
397
+ stdin.write('\x1b[3~') // Delete key
398
+ await delay(20)
399
+ let frame = lastFrame() ?? ''
400
+ ok(frame.includes('bc') && !frame.includes('abc'), 'TextInput Delete key (ESC[3~) deleted forward, not backward')
401
+
402
+ // Now type a word and delete it backward with Ctrl+Backspace.
403
+ stdin.write('hello world')
404
+ await delay(10)
405
+ stdin.write('\x1b[3;5~') // Ctrl+Backspace
406
+ await delay(20)
407
+ frame = lastFrame() ?? ''
408
+ ok(frame.includes('hello ') && !frame.includes('world'), 'TextInput Ctrl+Backspace (ESC[3;5~) deleted the whole word backward')
409
+ unmount()
410
+ }
411
+
412
+ // ════════════════════════════════════════════════════════════════════════════
413
+ // TEST 8c — Composer: Backspace deletes backward, Delete deletes forward,
414
+ // Ctrl+Backspace / Alt+Backspace / Ctrl+W delete the whole word backward.
415
+ // ════════════════════════════════════════════════════════════════════════════
416
+ async function testComposerDeleteKeys() {
417
+ console.log('\n[UI 8c] composer Backspace / Delete / word-delete keys')
418
+ const submitted: string[] = []
419
+ const { stdin, unmount } = render(
420
+ <Composer
421
+ onSubmit={(text) => submitted.push(text)}
422
+ onStop={() => {}}
423
+ onQuit={() => {}}
424
+ typing={false}
425
+ commands={[]}
426
+ />,
427
+ )
428
+ await delay(20)
429
+
430
+ // Backspace (0x7f) deletes backward, not forward.
431
+ stdin.write('hello')
432
+ stdin.write(String.fromCharCode(127))
433
+ await delay(80) // outlast the 20ms paste-burst window so Enter submits
434
+ stdin.write('\r')
435
+ await delay(20)
436
+ ok(submitted[0] === 'hell', 'Composer Backspace (0x7f) deleted the char before the cursor')
437
+
438
+ // Delete key (ESC[3~) deletes FORWARD after Ctrl+A moves to the start.
439
+ stdin.write('hello')
440
+ stdin.write('\x01') // Ctrl+A → cursor at 0
441
+ stdin.write('\x1b[3~') // Delete key
442
+ await delay(80)
443
+ stdin.write('\r')
444
+ await delay(20)
445
+ ok(submitted[1] === 'ello', 'Composer Delete key (ESC[3~) deleted the char after the cursor')
446
+
447
+ // Ctrl+Backspace (ESC[3;5~) deletes the whole word backward.
448
+ stdin.write('hello world')
449
+ stdin.write('\x1b[3;5~')
450
+ await delay(80)
451
+ stdin.write('\r')
452
+ await delay(20)
453
+ ok(submitted[2] === 'hello ', 'Composer Ctrl+Backspace (ESC[3;5~) deleted the whole word backward')
454
+
455
+ // Alt+Backspace (ESC DEL) deletes the whole word backward too.
456
+ stdin.write('hello world')
457
+ stdin.write('\x1b\x7f')
458
+ await delay(80)
459
+ stdin.write('\r')
460
+ await delay(20)
461
+ ok(submitted[3] === 'hello ', 'Composer Alt+Backspace (ESC DEL) deleted the whole word backward')
462
+
463
+ // Ctrl+W (0x17) still deletes the whole word backward.
464
+ stdin.write('hello world')
465
+ stdin.write('\x17')
466
+ await delay(80)
467
+ stdin.write('\r')
468
+ await delay(20)
469
+ ok(submitted[4] === 'hello ', 'Composer Ctrl+W still deletes the whole word backward')
470
+
471
+ unmount()
472
+ }
473
+
474
+ // ════════════════════════════════════════════════════════════════════════════
475
+ // TEST 9 — Codex-style composer keeps multiline pastes intact and grows/shrinks
476
+ // ════════════════════════════════════════════════════════════════════════════
477
+ async function testComposerMultilinePaste() {
478
+ console.log('\n[UI 9] composer multiline paste + framed auto-height input')
479
+ const submitted: string[] = []
480
+ const { stdin, lastFrame, unmount } = render(
481
+ <Composer
482
+ onSubmit={(text) => submitted.push(text)}
483
+ onStop={() => {}}
484
+ onQuit={() => {}}
485
+ typing={false}
486
+ commands={[]}
487
+ />,
488
+ )
489
+ await delay(20)
490
+ const initial = lastFrame() ?? ''
491
+ ok(initial.includes('╭') && initial.includes('╰'), 'composer has a visible bordered input boundary')
492
+ ok(initial.includes('Enter 发送') && initial.includes('Ctrl+J 换行'), 'composer shows Codex-style submit/newline hints')
493
+
494
+ stdin.write('\x1b[200~第一行\r\n第二行\r第三行\x1b[201~')
495
+ await delay(20)
496
+ const bracketed = lastFrame() ?? ''
497
+ ok(bracketed.includes('第一行') && bracketed.includes('第二行') && bracketed.includes('第三行'), 'bracketed multiline paste preserves every line')
498
+ ok(bracketed.includes('3 行'), 'input grows to report all pasted lines')
499
+ ok(submitted.length === 0, 'newlines inside a bracketed paste do not submit partial messages')
500
+ stdin.write('\r')
501
+ await delay(20)
502
+ ok(submitted[0] === '第一行\n第二行\n第三行', 'one Enter submits the complete normalized bracketed paste')
503
+ ok((lastFrame() ?? '').includes('1 行'), 'composer shrinks back after submission')
504
+
505
+ // Simulate terminals that split clipboard input into text + Enter events instead
506
+ // of producing a bracketed paste event (common through ConPTY/SSH/tmux chains).
507
+ stdin.write('first sentence')
508
+ stdin.write('\r')
509
+ stdin.write('second sentence')
510
+ stdin.write('\r')
511
+ stdin.write('last sentence')
512
+ await delay(10)
513
+ const burst = lastFrame() ?? ''
514
+ ok(burst.includes('first sentence') && burst.includes('second sentence') && burst.includes('last sentence'), 'paste burst keeps all split text chunks')
515
+ ok(submitted.length === 1, 'paste-burst Enter events become newlines instead of partial submissions')
516
+ await delay(80)
517
+ stdin.write('\r')
518
+ await delay(20)
519
+ ok(submitted[1] === 'first sentence\nsecond sentence\nlast sentence', 'Enter after the burst submits the complete multiline text once')
520
+
521
+ // Windows Terminal win32-input-mode preserves SHIFT_PRESSED in the key
522
+ // record. The decoder turns that into CSI-u before Ink sees the keypress.
523
+ const windowsInput = new WindowsInputDecoder()
524
+ stdin.write('Windows first line')
525
+ stdin.write(windowsInput.push('\x1b[16;42;0;1;16;1_\x1b[13;28;13;1;16;1_'))
526
+ stdin.write(windowsInput.push('\x1b[13;28;13;0;16;1_\x1b[16;42;0;0;0;1_'))
527
+ stdin.write('Windows second line')
528
+ await delay(20)
529
+ ok(submitted.length === 2, 'Windows Shift+Enter inserts a newline instead of submitting')
530
+ stdin.write('\r')
531
+ await delay(20)
532
+ ok(submitted[2] === 'Windows first line\nWindows second line', 'plain Windows Enter submits the multiline message')
533
+
534
+ const split = new WindowsInputDecoder()
535
+ ok(split.push('\x1b[13;28;13;1;16') === '', 'split Windows key record waits for its trailing bytes')
536
+ ok(split.push(';1_') === '\x1b[13;2u', 'split Windows Shift+Enter record decodes after completion')
537
+ ok(split.push('\x1b[13;28;13;1;0;1_') === '\r', 'plain Windows Enter remains a submit event')
538
+ ok(split.push('\x1b[65;30;65;1;16;1_\x1b[65;30;97;0;0;1_') === 'A', 'Windows key release does not duplicate typed text')
539
+ ok(split.push('\x1b[A') === '\x1b[A', 'ordinary VT sequences pass through unchanged')
540
+
541
+ stdin.write('xterm first line')
542
+ stdin.write('\x1b[27;2;13~')
543
+ stdin.write('xterm second line')
544
+ await delay(20)
545
+ ok(submitted.length === 3, 'xterm modifyOtherKeys Shift+Enter also inserts a newline')
546
+ stdin.write('\r')
547
+ await delay(20)
548
+ ok(submitted[3] === 'xterm first line\nxterm second line', 'xterm Shift+Enter content submits intact')
549
+ unmount()
550
+ }
551
+
552
+ // ════════════════════════════════════════════════════════════════════════════
553
+ // TEST 10 — Working text uses a moving multi-level brightness wave
554
+ // ════════════════════════════════════════════════════════════════════════════
555
+ function testWorkingShimmer() {
556
+ console.log('\n[UI 9] Working brightness animation')
557
+ function colorsAt(frame: number): string[] {
558
+ return shimmerText('Working', frame).map(node => (
559
+ React.isValidElement<{ color?: string }>(node) ? (node.props.color ?? '') : ''
560
+ ))
561
+ }
562
+ const frame0 = colorsAt(0)
563
+ const frame1 = colorsAt(1)
564
+ const wrapped = colorsAt('Working'.length)
565
+ ok(new Set(frame0).size >= 4, 'Working text uses several brightness levels instead of one dim color')
566
+ ok(frame0[0] === '#ffffff' && frame1[1] === '#ffffff', 'brightest point advances across the text between frames')
567
+ ok(wrapped[0] === '#ffffff', 'brightness wave wraps continuously to the start of the text')
568
+ }
569
+
570
+ // ════════════════════════════════════════════════════════════════════════════
571
+ // TEST 10 — reasoning / thinking entries are rendered, not skipped
572
+ // ════════════════════════════════════════════════════════════════════════════
573
+ function testReasoningViews() {
574
+ console.log('\n[UI 10] reasoning/thinking entries rendered')
575
+ // Codex encrypted reasoning → fixed label (matches web viewer)
576
+ const enc = viewsForEntry({ type: 'response_item', payload: { type: 'reasoning', encrypted_content: 'blob' } } as any)
577
+ ok(enc.length === 1 && enc[0].kind === 'reasoning' && (enc[0] as any).text.includes('闭源'), 'encrypted reasoning → label')
578
+ // Codex reasoning with summary text
579
+ const sum = viewsForEntry({ type: 'response_item', payload: { type: 'reasoning', summary: [{ type: 'summary_text', text: '先读文件再改' }] } } as any)
580
+ ok(sum.length === 1 && sum[0].kind === 'reasoning' && (sum[0] as any).text === '先读文件再改', 'reasoning summary shown')
581
+ // Claude thinking with body
582
+ const th = viewsForEntry({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'thinking', thinking: '我在想...' }] } } as any)
583
+ ok(th.length === 1 && th[0].kind === 'reasoning' && (th[0] as any).text === '我在想...', 'claude thinking body shown')
584
+ // Claude encrypted/empty thinking → hidden label
585
+ const empty = viewsForEntry({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'thinking', thinking: '' }] } } as any)
586
+ ok(empty.length === 1 && empty[0].kind === 'reasoning' && (empty[0] as any).text === '思考内容被隐藏', 'empty thinking → hidden label')
587
+ }
588
+
589
+ // ════════════════════════════════════════════════════════════════════════════
590
+ // TEST 11 — current Codex custom tool calls show their nested shell command
591
+ // ════════════════════════════════════════════════════════════════════════════
592
+ function testCustomToolCallViews() {
593
+ console.log('\n[UI 11] custom_tool_call commands rendered')
594
+ const call = viewsForEntry({
595
+ type: 'response_item',
596
+ payload: {
597
+ type: 'custom_tool_call',
598
+ name: 'exec',
599
+ call_id: 'call_1',
600
+ input: 'const r = await tools.exec_command({\n cmd: "rg -n \\\"needle\\\" mobius/tui/src",\n workdir: "/repo"\n});\ntext(r.output);',
601
+ },
602
+ } as any)
603
+ ok(call.length === 1 && call[0].kind === 'tool_call', 'custom tool call is not skipped')
604
+ ok(call[0].kind === 'tool_call' && call[0].toolName === 'exec_command', 'nested exec_command tool name extracted')
605
+ ok(call[0].kind === 'tool_call' && call[0].summary.includes('rg -n "needle" mobius/tui/src'), 'nested shell command shown')
606
+
607
+ const singleQuoted = viewsForEntry({
608
+ type: 'response_item',
609
+ payload: { type: 'custom_tool_call', name: 'exec', input: "await tools.exec_command({ cmd: 'npm run typecheck', workdir: '/repo' })" },
610
+ } as any)
611
+ ok(singleQuoted[0].kind === 'tool_call' && singleQuoted[0].summary === 'npm run typecheck', 'JavaScript single-quoted command parsed')
612
+
613
+ const parallelWrapped = viewsForEntry({
614
+ type: 'response_item',
615
+ payload: { type: 'custom_tool_call', name: 'exec', input: 'const all = await Promise.all([tools.exec_command({"cmd":"npm run test:ui"})])' },
616
+ } as any)
617
+ ok(parallelWrapped[0].kind === 'tool_call' && parallelWrapped[0].toolName === 'exec_command' && parallelWrapped[0].summary === 'npm run test:ui', 'transport helper before tools.exec_command is ignored')
618
+
619
+ const output = viewsForEntry({
620
+ type: 'response_item',
621
+ payload: {
622
+ type: 'custom_tool_call_output',
623
+ call_id: 'call_1',
624
+ output: [{ type: 'input_text', text: 'Script completed\nWall time 0.2 seconds' }],
625
+ },
626
+ } as any)
627
+ const o0 = output[0]
628
+ ok(output.length === 1 && o0.kind === 'tool_result' && o0.text.includes('Script completed'), 'custom tool output rendered as a tool_result line (accumulated mode)')
629
+
630
+ const legacy = viewsForEntry({
631
+ type: 'response_item',
632
+ payload: { type: 'function_call', name: 'exec_command', arguments: '{"cmd":"git status --short"}' },
633
+ } as any)
634
+ ok(legacy[0].kind === 'tool_call' && legacy[0].summary === 'git status --short', 'legacy function_call command remains supported')
635
+ }
636
+
637
+ // ════════════════════════════════════════════════════════════════════════════
638
+ // TEST 11b — claude-code MCP 工具渲染与 codex 对齐 (统一)
639
+ // ════════════════════════════════════════════════════════════════════════════
640
+ function testClaudeMcpUnified() {
641
+ console.log('\n[UI 11b] claude MCP tool name/summary/result unified with codex')
642
+ // ① 长名 mcp__aimux__remote_exec_command → 标签 "运行命令" (与 codex exec 一致)
643
+ ok(toolLabel('mcp__aimux__remote_exec_command') === '运行命令', 'mcp__aimux__remote_exec_command label maps to 运行命令 (same as codex exec)')
644
+ ok(toolLabel('mcp__aimux__send_files') === 'send_files', 'unknown MCP tool falls back to short name without mcp__server__ prefix')
645
+ ok(toolLabel('exec_command') === '运行命令', 'codex short name still maps (unchanged)')
646
+
647
+ // ② summary: MCP remote_exec_command 抽出 cmd, 与 codex exec_command 一致 (不带 "cmd:" 前缀)
648
+ const call = viewsForEntry({
649
+ type: 'assistant',
650
+ message: { content: [{ type: 'tool_use', id: 't1', name: 'mcp__aimux__remote_exec_command', input: { cmd: 'cat /etc/hosts' } }] },
651
+ } as any)
652
+ ok(call.length === 1 && call[0].kind === 'tool_call' && call[0].summary === 'cat /etc/hosts', 'MCP remote_exec_command summary is the bare cmd (unified with codex)')
653
+
654
+ // ③ 结果: aimux 返回的 JSON {"output":"...","exit_code":0} 解包成纯 output, 并清 OSC 标题 + AIMUX_EXIT 标记
655
+ const noisy = `line1\n${'\x1b]0;root@h: ~\x07'}line2\n__AIMUX_EXIT_deadbeef__:0`
656
+ const resultEntry = {
657
+ type: 'user',
658
+ message: { content: [{ type: 'tool_result', tool_use_id: 't1', content: JSON.stringify({ output: noisy, exit_code: 0 }) }] },
659
+ }
660
+ const r = viewsForEntry(resultEntry as any)
661
+ ok(r.length === 1 && r[0].kind === 'tool_result', 'MCP JSON result rendered as tool_result')
662
+ ok(r[0].kind === 'tool_result' && r[0].text === 'line1\nline2', 'JSON output unwrapped + OSC title + AIMUX_EXIT marker stripped (clean, like codex)')
663
+
664
+ // ④ 守卫: 本身是 JSON 的文件内容 (无 output 字段) 不被误解包
665
+ const plain = viewsForEntry({
666
+ type: 'user',
667
+ message: { content: [{ type: 'tool_result', tool_use_id: 't2', content: '{"name":"config","version":1}' }] },
668
+ } as any)
669
+ ok(plain[0].kind === 'tool_result' && plain[0].text === '{"name":"config","version":1}', 'plain JSON file content is not unwrapped (no output field)')
670
+ }
671
+
672
+ // ════════════════════════════════════════════════════════════════════════════
673
+ // TEST 12 — SSE "terminated" is silent (server/proxy dropped the stream)
674
+ // ════════════════════════════════════════════════════════════════════════════
675
+ async function testSseTerminatedSilent() {
676
+ console.log('\n[UI 12] SSE "terminated" is silent, not an error')
677
+ let errorMsg: string | null = null
678
+ let opened = false, closed = false
679
+ installMock(async () => ({
680
+ ok: true, status: 200,
681
+ body: { getReader: () => ({ read: async () => { throw new Error('terminated') } }) },
682
+ }) as any)
683
+ try {
684
+ const conn = new SseConnection('http://mock/events', {
685
+ onOpen: () => { opened = true },
686
+ onError: (m) => { errorMsg = m },
687
+ onClose: () => { closed = true },
688
+ })
689
+ await conn.start()
690
+ ok(opened, 'SSE opened before the drop')
691
+ ok(errorMsg === null, '"terminated" did not raise a user-facing error')
692
+ ok(closed, 'onClose fired so the hook can reconnect')
693
+ } finally { restoreFetch() }
694
+ }
695
+
696
+ // ════════════════════════════════════════════════════════════════════════════
697
+ // TEST 13 — SSE reconnect replays missed entries (no silent freeze)
698
+ // ════════════════════════════════════════════════════════════════════════════
699
+ async function testChatSseReconnects() {
700
+ console.log('\n[UI 13] SSE reconnect replays missed entries')
701
+ const client = new MobiusClient('http://mock.local', 'mock-jwt-token')
702
+ const ready: ReadyState = {
703
+ project: { id: 'p1', name: '测试项目' },
704
+ issue: { id: 'i1', project_id: 'p1', title: '测试任务' },
705
+ prefs: { model: 'codex', language: 'zh', excluded_skill_ids: [], excluded_memory_ids: [] },
706
+ }
707
+ const frame = (event: string, payload: Record<string, unknown>) =>
708
+ `event: ${event}\ndata: ${JSON.stringify({ event, ...payload })}\n\n`
709
+ const assistantText = (text: string) => ({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text }] } })
710
+ let sseCall = 0
711
+ installMock((url, init) => {
712
+ if (url.includes('/events')) {
713
+ sseCall++
714
+ if (sseCall === 1) {
715
+ // first connection: one entry, then the stream drops ("terminated")
716
+ return new Response(new RS({
717
+ start(c: any) {
718
+ c.enqueue(enc.encode(frame('subscribed', { session: {} })))
719
+ c.enqueue(enc.encode(frame('jsonl_entry', { session_id: 's1', entry: assistantText('第一条') })))
720
+ setTimeout(() => { try { c.error(new Error('terminated')) } catch { /* already closed */ } }, 30)
721
+ },
722
+ }), { status: 200, headers: { 'content-type': 'text/event-stream' } })
723
+ }
724
+ // reconnect: server replays history including a NEW second entry
725
+ return new Response(new RS({
726
+ start(c: any) {
727
+ c.enqueue(enc.encode(frame('subscribed', { session: {} })))
728
+ c.enqueue(enc.encode(frame('jsonl_history', { entries: [assistantText('第一条'), assistantText('第二条')], done: true })))
729
+ },
730
+ }), { status: 200, headers: { 'content-type': 'text/event-stream' } })
731
+ }
732
+ if (url.endsWith('/messages') && init?.method === 'POST') return jsonResponse({ ok: true, session_id: 's1', turn_number: 1 })
733
+ if (url.endsWith('/api/sessions/s1/status')) return jsonResponse({ session_id: 's1', alive: true, working: false })
734
+ if (url.includes('/sessions') && init?.method === 'POST') return jsonResponse({ session_id: 's1' })
735
+ return jsonResponse({ error: 'no mock' }, 404)
736
+ })
737
+ try {
738
+ const { stdin, lastFrame, unmount } = render(
739
+ <ChatScreen client={client} ready={ready} webUserId="test-user" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
740
+ )
741
+ await delay(40)
742
+ // Ink's test stdin treats one chunk as one keypress. Send text and Enter as
743
+ // separate chunks, matching a real terminal and the main chat test above.
744
+ stdin.write('hi')
745
+ await delay(30)
746
+ stdin.write('\r')
747
+ const replayed = await waitFor(lastFrame, '第二条', 4000)
748
+ const out = lastFrame() ?? ''
749
+ unmount()
750
+ ok(out.includes('第一条'), 'pre-drop entry shown')
751
+ ok(replayed, 'reconnect replayed the missed entry')
752
+ ok(sseCall >= 2, 'SSE was reconnected after the drop')
753
+ } finally { restoreFetch() }
754
+ }
755
+
756
+ // ════════════════════════════════════════════════════════════════════════════
757
+ // TEST 14 — sending after an idle completed session reopens its closed SSE
758
+ // ════════════════════════════════════════════════════════════════════════════
759
+ async function testIdleCompletedSessionReopensSseOnSend() {
760
+ console.log('\n[UI 14] Idle completed session reopens SSE on the next send')
761
+ const client = new MobiusClient('http://mock.local', 'mock-jwt-token')
762
+ const ready: ReadyState = {
763
+ project: { id: 'p1', name: '测试项目' },
764
+ issue: { id: 'i1', project_id: 'p1', title: '测试任务' },
765
+ prefs: { model: 'codex', language: 'zh', excluded_skill_ids: [], excluded_memory_ids: [] },
766
+ }
767
+ const frame = (event: string, payload: Record<string, unknown>) =>
768
+ `event: ${event}\ndata: ${JSON.stringify({ event, ...payload })}\n\n`
769
+ let sseCall = 0
770
+ let liveController: any = null
771
+ installMock((url, init) => {
772
+ if (url.includes('/events')) {
773
+ sseCall++
774
+ if (sseCall === 1) {
775
+ return new Response(new RS({
776
+ start(c: any) {
777
+ c.enqueue(enc.encode(frame('subscribed', { session: {} })))
778
+ // The worker is already complete (alive=false below). Later the
779
+ // proxy drops this idle stream, so onClose correctly does not retry.
780
+ setTimeout(() => { try { c.error(new Error('terminated')) } catch { /* closed */ } }, 40)
781
+ },
782
+ }), { status: 200, headers: { 'content-type': 'text/event-stream' } })
783
+ }
784
+ return new Response(new RS({
785
+ start(c: any) {
786
+ liveController = c
787
+ c.enqueue(enc.encode(frame('subscribed', { session: {} })))
788
+ },
789
+ }), { status: 200, headers: { 'content-type': 'text/event-stream' } })
790
+ }
791
+ if (url.endsWith('/messages') && init?.method === 'POST') {
792
+ setTimeout(() => {
793
+ liveController?.enqueue(enc.encode(frame('jsonl_entry', {
794
+ session_id: 's1',
795
+ entry: { type: 'user', uuid: 'idle-user-1', message: { role: 'user', content: 'q' } },
796
+ })))
797
+ liveController?.enqueue(enc.encode(frame('jsonl_entry', {
798
+ session_id: 's1',
799
+ entry: { type: 'assistant', uuid: 'idle-assistant-1', message: { role: 'assistant', content: [{ type: 'text', text: 'TUI 已恢复接收' }] } },
800
+ })))
801
+ }, 30)
802
+ return jsonResponse({ ok: true, session_id: 's1', turn_number: 2 })
803
+ }
804
+ if (url.endsWith('/api/sessions/s1/status')) {
805
+ return jsonResponse({ session_id: 's1', alive: false, working: false })
806
+ }
807
+ return jsonResponse({ error: 'no mock' }, 404)
808
+ })
809
+ try {
810
+ const { stdin, lastFrame, unmount } = render(
811
+ <ChatScreen client={client} ready={ready} webUserId="test-user" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
812
+ )
813
+ await delay(180)
814
+ ok(sseCall === 1, 'completed idle session did not reconnect by itself')
815
+ stdin.write('q'); await delay(30); stdin.write('\r')
816
+ const received = await waitFor(lastFrame, 'TUI 已恢复接收', 3000)
817
+ const out = lastFrame() ?? ''
818
+ unmount()
819
+ ok(sseCall >= 2, 'sending reopened the closed SSE stream')
820
+ ok(received && out.includes('q'), 'the new user turn and assistant reply are visible in TUI')
821
+ } finally { restoreFetch() }
822
+ }
823
+
824
+ // ════════════════════════════════════════════════════════════════════════════
825
+ // TEST 15 — message dispatch retries a transient 502
826
+ // ════════════════════════════════════════════════════════════════════════════
827
+ async function testSendRetries502() {
828
+ console.log('\n[UI 15] message dispatch retries transient 502')
829
+ const client = new MobiusClient('http://mock.local', 'mock-jwt-token')
830
+ const ready: ReadyState = {
831
+ project: { id: 'p1', name: 'p' },
832
+ issue: { id: 'i1', project_id: 'p1', title: 't' },
833
+ prefs: { model: 'codex', language: 'zh', excluded_skill_ids: [], excluded_memory_ids: [] },
834
+ }
835
+ let msgCall = 0
836
+ installMock((url, init) => {
837
+ if (url.includes('/events')) {
838
+ return new Response(new RS({ start(c: any) { c.enqueue(enc.encode('event: subscribed\ndata: {"event":"subscribed","session":{}}\n\n')) } }), { status: 200, headers: { 'content-type': 'text/event-stream' } })
839
+ }
840
+ if (url.endsWith('/messages') && init?.method === 'POST') {
841
+ msgCall++
842
+ if (msgCall === 1) return jsonResponse({ error: 'bad gateway' }, 502) // transient
843
+ return jsonResponse({ ok: true, session_id: 's1', turn_number: 1 }) // retry succeeds
844
+ }
845
+ if (url.endsWith('/api/sessions/s1/status')) return jsonResponse({ session_id: 's1', alive: true, working: false })
846
+ if (url.includes('/sessions') && init?.method === 'POST') return jsonResponse({ session_id: 's1' })
847
+ return jsonResponse({ error: 'no mock' }, 404)
848
+ })
849
+ try {
850
+ const { stdin, lastFrame, unmount } = render(
851
+ <ChatScreen client={client} ready={ready} webUserId="u" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
852
+ )
853
+ await delay(40)
854
+ stdin.write('hi'); await delay(30); stdin.write('\r')
855
+ await delay(3000) // first 502 (~0ms) + backoff ~500ms + retry succeeds
856
+ const out = lastFrame() ?? ''
857
+ unmount()
858
+ ok(msgCall >= 2, 'message dispatch was retried after a 502')
859
+ ok(!out.includes('HTTP 502'), 'transient 502 absorbed, not surfaced as a hard error')
860
+ } finally { restoreFetch() }
861
+ }
862
+
863
+ async function main() {
864
+ await testLogin()
865
+ await testChat()
866
+ await testResumedWorkingStatus()
867
+ testMarkdownCodeRendering()
868
+ await testPrepRender()
869
+ await testSelectViewport()
870
+ await testProjectPickerEscQuit()
871
+ await testTextInputBackspace()
872
+ await testTextInputDeleteKeys()
873
+ await testComposerDeleteKeys()
874
+ await testComposerMultilinePaste()
875
+ testWorkingShimmer()
876
+ testReasoningViews()
877
+ testCustomToolCallViews()
878
+ testClaudeMcpUnified()
879
+ await testSseTerminatedSilent()
880
+ await testChatSseReconnects()
881
+ await testIdleCompletedSessionReopensSseOnSend()
882
+ await testSendRetries502()
883
+ // cleanup temp home
884
+ try { fs.rmSync(TMP_HOME, { recursive: true, force: true }) } catch { /* ignore */ }
885
+ console.log(`\n==== UI RESULT: ${pass} passed, ${fail} failed ====\n`)
886
+ process.exit(fail === 0 ? 0 : 1)
887
+ }
888
+
889
+ main().catch((e) => { console.error('FATAL', e); process.exit(2) })