@mobius-os/mobius 0.3.27 → 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.
- package/install.ps1 +265 -0
- package/package.json +23 -7
- package/scripts/build-python-bundles.sh +129 -0
- package/src/App.tsx +2 -2
- package/src/components/Chat.tsx +93 -183
- package/src/components/ConfigFlow.tsx +2 -0
- package/src/components/primitives.tsx +113 -6
- package/src/lib/screen-text.ts +73 -27
- package/src/lib/windows-input.ts +186 -0
- package/src/main.tsx +5 -1
- package/tests/aimux.test.tsx +210 -0
- package/tests/flow.test.tsx +211 -0
- package/tests/integration.test.ts +114 -0
- package/tests/preview.tsx +104 -0
- package/tests/reconnect.test.tsx +170 -0
- package/tests/resume.test.tsx +73 -0
- package/tests/screen.test.tsx +117 -0
- package/tests/scroll.test.tsx +252 -0
- package/tests/selection.test.tsx +219 -0
- package/tests/ui.test.tsx +889 -0
- package/tsconfig.json +19 -0
- package/uninstall.ps1 +144 -0
package/src/lib/screen-text.ts
CHANGED
|
@@ -103,8 +103,29 @@ export function headTailLines(text: string, width: number, maxLines: number): st
|
|
|
103
103
|
export interface ScreenRows {
|
|
104
104
|
/** Whether this view renders with a leading blank row (its Box has marginTop). */
|
|
105
105
|
marginTop: boolean
|
|
106
|
-
/** Full screen
|
|
107
|
-
rows:
|
|
106
|
+
/** Full screen rows: a plain layout/copy projection plus its styled ANSI text. */
|
|
107
|
+
rows: ScreenRow[]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type ScreenRowTone =
|
|
111
|
+
| 'normal'
|
|
112
|
+
| 'user'
|
|
113
|
+
| 'tool'
|
|
114
|
+
| 'tool_result'
|
|
115
|
+
| 'tool_error'
|
|
116
|
+
| 'edit_header'
|
|
117
|
+
| 'edit_old'
|
|
118
|
+
| 'edit_new'
|
|
119
|
+
| 'reasoning'
|
|
120
|
+
| 'system'
|
|
121
|
+
| 'error'
|
|
122
|
+
|
|
123
|
+
export interface ScreenRow {
|
|
124
|
+
/** Visible text used for geometry, hit-testing, and clipboard extraction. */
|
|
125
|
+
plain: string
|
|
126
|
+
/** Same row with Markdown/syntax ANSI styling preserved for terminal output. */
|
|
127
|
+
styled: string
|
|
128
|
+
tone: ScreenRowTone
|
|
108
129
|
}
|
|
109
130
|
|
|
110
131
|
const textWidth = (columns: number) => Math.max(8, columns - 4)
|
|
@@ -114,72 +135,97 @@ const fullWidth = (columns: number) => Math.max(1, columns - 2) // root paddingX
|
|
|
114
135
|
export function viewScreenRows(view: EntryView, columns: number): ScreenRows {
|
|
115
136
|
const width = textWidth(columns)
|
|
116
137
|
const full = fullWidth(columns)
|
|
117
|
-
const
|
|
138
|
+
const row = (styled: string, tone: ScreenRowTone = 'normal'): ScreenRow => ({
|
|
139
|
+
plain: stripAnsi(styled),
|
|
140
|
+
styled,
|
|
141
|
+
tone,
|
|
142
|
+
})
|
|
143
|
+
// Keep the original styled string and let Ink perform its ANSI-aware
|
|
144
|
+
// truncate-end rendering. The plain projection mirrors the visible row.
|
|
145
|
+
const fit = (rows: string[], tone: ScreenRowTone = 'normal'): ScreenRow[] => rows.map((styled) => ({
|
|
146
|
+
plain: truncateText(stripAnsi(styled), full),
|
|
147
|
+
styled,
|
|
148
|
+
tone,
|
|
149
|
+
}))
|
|
150
|
+
const wrap = (styled: string, tone: ScreenRowTone = 'normal'): ScreenRow[] => (
|
|
151
|
+
wrapAnsi(styled, full, { trim: false, hard: true }).split('\n').map(text => row(text, tone))
|
|
152
|
+
)
|
|
118
153
|
switch (view.kind) {
|
|
119
154
|
case 'skip':
|
|
120
155
|
return { marginTop: false, rows: [] }
|
|
121
156
|
case 'user': {
|
|
122
157
|
const rows = view.text.split('\n').map((l, i) => (i === 0 ? `› ${l}` : ` ${l}`))
|
|
123
|
-
return { marginTop: true, rows: fit(rows) }
|
|
158
|
+
return { marginTop: true, rows: fit(rows, 'user') }
|
|
124
159
|
}
|
|
125
160
|
case 'assistant': {
|
|
126
161
|
const md = renderMarkdownLines(view.text)
|
|
127
|
-
const rows:
|
|
162
|
+
const rows: ScreenRow[] = []
|
|
128
163
|
md.forEach((line, i) => {
|
|
129
164
|
const prefix = i === 0 ? '• ' : ' '
|
|
130
|
-
const fullLine = prefix +
|
|
131
|
-
if (line.code) rows.push(
|
|
132
|
-
else rows.push(...
|
|
165
|
+
const fullLine = prefix + (line.text || ' ')
|
|
166
|
+
if (line.code) rows.push(...fit([fullLine]))
|
|
167
|
+
else rows.push(...wrap(fullLine))
|
|
133
168
|
})
|
|
134
169
|
return { marginTop: true, rows }
|
|
135
170
|
}
|
|
136
171
|
case 'tool_call': {
|
|
137
172
|
const head = clampLines(`${toolLabel(view.toolName)} ${view.summary}`.trim(), width - 2, 1)[0]
|
|
138
|
-
const rows = [`• ${head}`]
|
|
139
|
-
if (view.result) rows.push(
|
|
140
|
-
|
|
173
|
+
const rows = fit([`• ${head}`], 'tool')
|
|
174
|
+
if (view.result) rows.push(...fit(
|
|
175
|
+
[` └ ${clampLines(view.result.text, width - 4, 1)[0] || '(无输出)'}`],
|
|
176
|
+
view.result.isError ? 'tool_error' : 'tool_result',
|
|
177
|
+
))
|
|
178
|
+
return { marginTop: true, rows }
|
|
141
179
|
}
|
|
142
180
|
case 'tool_result': {
|
|
143
181
|
const lines = headTailLines(view.text, width - 4, 5)
|
|
144
|
-
return { marginTop: false, rows: fit(
|
|
182
|
+
return { marginTop: false, rows: fit(
|
|
183
|
+
lines.map((l, i) => `${i === 0 ? ' └ ' : ' '}${l}`),
|
|
184
|
+
view.isError ? 'tool_error' : 'tool_result',
|
|
185
|
+
) }
|
|
145
186
|
}
|
|
146
187
|
case 'code_edit': {
|
|
147
|
-
const rows = [`✎ 编辑 ${view.filePath || '(未指定文件)'}`]
|
|
148
|
-
if (view.oldString) rows.push(...view.oldString.split('\n').map((l) => ` − ${l}`))
|
|
149
|
-
if (view.newString) rows.push(...view.newString.split('\n').map((l) => ` + ${l}`))
|
|
150
|
-
return { marginTop: true, rows
|
|
188
|
+
const rows = fit([`✎ 编辑 ${view.filePath || '(未指定文件)'}`], 'edit_header')
|
|
189
|
+
if (view.oldString) rows.push(...fit(view.oldString.split('\n').map((l) => ` − ${l}`), 'edit_old'))
|
|
190
|
+
if (view.newString) rows.push(...fit(view.newString.split('\n').map((l) => ` + ${l}`), 'edit_new'))
|
|
191
|
+
return { marginTop: true, rows }
|
|
151
192
|
}
|
|
152
193
|
case 'write_file': {
|
|
153
|
-
const rows = [`✎ 写入 ${view.filePath || '(未指定文件)'}`]
|
|
154
|
-
rows.push(...view.content.split('\n').map((l) => ` + ${l}`))
|
|
155
|
-
return { marginTop: true, rows
|
|
194
|
+
const rows = fit([`✎ 写入 ${view.filePath || '(未指定文件)'}`], 'edit_header')
|
|
195
|
+
rows.push(...fit(view.content.split('\n').map((l) => ` + ${l}`), 'edit_new'))
|
|
196
|
+
return { marginTop: true, rows }
|
|
156
197
|
}
|
|
157
198
|
case 'reasoning': {
|
|
158
199
|
const lines = clampLines(view.text, width - 4, 2)
|
|
159
|
-
return { marginTop: true, rows: fit(lines.map((l, i) => `${i === 0 ? ' ◇ ' : ' '}${l}`)) }
|
|
200
|
+
return { marginTop: true, rows: fit(lines.map((l, i) => `${i === 0 ? ' ◇ ' : ' '}${l}`), 'reasoning') }
|
|
160
201
|
}
|
|
161
202
|
case 'system':
|
|
162
|
-
return { marginTop: false, rows: fit([` ${clampLines(view.text, width - 2, 2)[0]}`]) }
|
|
203
|
+
return { marginTop: false, rows: fit([` ${clampLines(view.text, width - 2, 2)[0]}`], 'system') }
|
|
163
204
|
case 'error': {
|
|
164
205
|
const rows = view.text.split('\n').map((l, i) => `${i === 0 ? '⚠ ' : ' '}${l}`)
|
|
165
|
-
return { marginTop: true, rows: fit(rows) }
|
|
206
|
+
return { marginTop: true, rows: fit(rows, 'error') }
|
|
166
207
|
}
|
|
167
208
|
default:
|
|
168
209
|
return { marginTop: false, rows: [] }
|
|
169
210
|
}
|
|
170
211
|
}
|
|
171
212
|
|
|
172
|
-
/** Flatten a whole entry
|
|
173
|
-
export function
|
|
174
|
-
const lines:
|
|
213
|
+
/** Flatten a whole entry into stable rows, with margins represented explicitly. */
|
|
214
|
+
export function entryScreenRows(views: EntryView[], columns: number): ScreenRow[] {
|
|
215
|
+
const lines: ScreenRow[] = []
|
|
175
216
|
for (const v of views) {
|
|
176
217
|
const { marginTop, rows } = viewScreenRows(v, columns)
|
|
177
|
-
if (marginTop) lines.push('')
|
|
218
|
+
if (marginTop) lines.push({ plain: '', styled: '', tone: 'normal' })
|
|
178
219
|
lines.push(...rows)
|
|
179
220
|
}
|
|
180
221
|
return lines
|
|
181
222
|
}
|
|
182
223
|
|
|
224
|
+
/** Plain projection used by fitting, geometry, hit-testing, and copying. */
|
|
225
|
+
export function entryScreenLines(views: EntryView[], columns: number): string[] {
|
|
226
|
+
return entryScreenRows(views, columns).map(row => row.plain)
|
|
227
|
+
}
|
|
228
|
+
|
|
183
229
|
// ── vertical geometry ────────────────────────────────────────────────────────
|
|
184
230
|
export interface TranscriptGeometry {
|
|
185
231
|
/** Screen row where the transcript box's top edge sits. */
|
|
@@ -269,7 +315,7 @@ function charAtDisplayWidth(line: string, col: number): number {
|
|
|
269
315
|
const w = displayWidth(ch)
|
|
270
316
|
if (col < acc + w) return i
|
|
271
317
|
acc += w
|
|
272
|
-
i
|
|
318
|
+
i += ch.length
|
|
273
319
|
}
|
|
274
320
|
return i
|
|
275
321
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { Transform } from 'node:stream'
|
|
2
|
+
|
|
3
|
+
const ENABLE_WIN32_INPUT_MODE = '\x1b[?9001h'
|
|
4
|
+
const DISABLE_WIN32_INPUT_MODE = '\x1b[?9001l'
|
|
5
|
+
|
|
6
|
+
const WIN32_KEY_RECORD_RE = /^\x1b\[(\d*);(\d*);(\d*);(\d*);(\d*);(\d*)_/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Decode Windows Terminal's win32-input-mode KEY_EVENT_RECORD sequences back
|
|
10
|
+
* into the VT input Ink expects. This preserves modifiers that legacy ConPTY
|
|
11
|
+
* input loses, most importantly the distinction between Enter and Shift+Enter.
|
|
12
|
+
*/
|
|
13
|
+
export class WindowsInputDecoder {
|
|
14
|
+
private buffer = ''
|
|
15
|
+
|
|
16
|
+
get hasPendingInput(): boolean {
|
|
17
|
+
return this.buffer.length > 0
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
push(chunk: string): string {
|
|
21
|
+
this.buffer += chunk
|
|
22
|
+
let output = ''
|
|
23
|
+
|
|
24
|
+
while (this.buffer) {
|
|
25
|
+
const start = this.buffer.indexOf('\x1b[')
|
|
26
|
+
if (start < 0) {
|
|
27
|
+
if (this.buffer.endsWith('\x1b')) {
|
|
28
|
+
output += this.buffer.slice(0, -1)
|
|
29
|
+
this.buffer = '\x1b'
|
|
30
|
+
} else {
|
|
31
|
+
output += this.buffer
|
|
32
|
+
this.buffer = ''
|
|
33
|
+
}
|
|
34
|
+
break
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
output += this.buffer.slice(0, start)
|
|
38
|
+
this.buffer = this.buffer.slice(start)
|
|
39
|
+
const match = WIN32_KEY_RECORD_RE.exec(this.buffer)
|
|
40
|
+
if (match) {
|
|
41
|
+
this.buffer = this.buffer.slice(match[0].length)
|
|
42
|
+
output += translateWindowsKeyRecord(match.slice(1))
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (isPartialWindowsKeyRecord(this.buffer)) break
|
|
47
|
+
|
|
48
|
+
// A normal VT sequence (arrows, mouse, paste markers, and so on) is not
|
|
49
|
+
// part of win32-input-mode. Release its ESC byte and scan the remainder.
|
|
50
|
+
output += this.buffer[0]
|
|
51
|
+
this.buffer = this.buffer.slice(1)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return output
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
flush(): string {
|
|
58
|
+
const remainder = this.buffer
|
|
59
|
+
this.buffer = ''
|
|
60
|
+
return remainder
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isPartialWindowsKeyRecord(value: string): boolean {
|
|
65
|
+
if (value === '\x1b' || value === '\x1b[') return true
|
|
66
|
+
if (!value.startsWith('\x1b[')) return false
|
|
67
|
+
const body = value.slice(2)
|
|
68
|
+
return /^[\d;]*$/.test(body) && body.split(';').length <= 6
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function numberParam(value: string | undefined, fallback: number): number {
|
|
72
|
+
return value === undefined || value === '' ? fallback : Number(value)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function translateWindowsKeyRecord(params: string[]): string {
|
|
76
|
+
const virtualKey = numberParam(params[0], 0)
|
|
77
|
+
const unicode = numberParam(params[2], 0)
|
|
78
|
+
const keyDown = numberParam(params[3], 1) !== 0
|
|
79
|
+
const controlState = numberParam(params[4], 0)
|
|
80
|
+
const repeat = Math.max(1, Math.min(100, numberParam(params[5], 1)))
|
|
81
|
+
if (!keyDown) return ''
|
|
82
|
+
|
|
83
|
+
const shift = (controlState & 0x0010) !== 0
|
|
84
|
+
const leftAlt = (controlState & 0x0002) !== 0
|
|
85
|
+
const rightAlt = (controlState & 0x0001) !== 0
|
|
86
|
+
const leftCtrl = (controlState & 0x0008) !== 0
|
|
87
|
+
const altGr = rightAlt && leftCtrl
|
|
88
|
+
|
|
89
|
+
// Modifier-only records carry no text and must not leak into the composer.
|
|
90
|
+
if (unicode === 0 && [0x10, 0x11, 0x12, 0x14, 0x5b, 0x5c].includes(virtualKey)) return ''
|
|
91
|
+
|
|
92
|
+
let encoded = ''
|
|
93
|
+
if (virtualKey === 0x0d) {
|
|
94
|
+
encoded = shift ? '\x1b[13;2u' : '\r'
|
|
95
|
+
} else if (virtualKey === 0x08) {
|
|
96
|
+
encoded = '\x7f'
|
|
97
|
+
} else if (virtualKey === 0x09) {
|
|
98
|
+
encoded = shift ? '\x1b[Z' : '\t'
|
|
99
|
+
} else if (virtualKey === 0x1b) {
|
|
100
|
+
encoded = '\x1b'
|
|
101
|
+
} else if (unicode !== 0) {
|
|
102
|
+
encoded = String.fromCharCode(unicode)
|
|
103
|
+
if ((leftAlt || rightAlt) && !altGr) encoded = `\x1b${encoded}`
|
|
104
|
+
} else {
|
|
105
|
+
encoded = virtualKeySequence(virtualKey, controlState)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return encoded.repeat(repeat)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function virtualKeySequence(virtualKey: number, controlState: number): string {
|
|
112
|
+
const shift = (controlState & 0x0010) !== 0
|
|
113
|
+
const alt = (controlState & 0x0003) !== 0
|
|
114
|
+
const ctrl = (controlState & 0x000c) !== 0
|
|
115
|
+
const modifier = 1 + (shift ? 1 : 0) + (alt ? 2 : 0) + (ctrl ? 4 : 0)
|
|
116
|
+
const suffix = modifier === 1 ? '' : `1;${modifier}`
|
|
117
|
+
const csiLetter: Record<number, string> = {
|
|
118
|
+
0x23: 'F', 0x24: 'H', 0x25: 'D', 0x26: 'A',
|
|
119
|
+
0x27: 'C', 0x28: 'B',
|
|
120
|
+
}
|
|
121
|
+
if (csiLetter[virtualKey]) return `\x1b[${suffix}${csiLetter[virtualKey]}`
|
|
122
|
+
|
|
123
|
+
const csiTilde: Record<number, number> = {
|
|
124
|
+
0x21: 5, 0x22: 6, 0x2d: 2, 0x2e: 3,
|
|
125
|
+
0x74: 15, 0x75: 17, 0x76: 18, 0x77: 19,
|
|
126
|
+
0x78: 20, 0x79: 21, 0x7a: 23, 0x7b: 24,
|
|
127
|
+
}
|
|
128
|
+
if (csiTilde[virtualKey]) {
|
|
129
|
+
const code = csiTilde[virtualKey]
|
|
130
|
+
return modifier === 1 ? `\x1b[${code}~` : `\x1b[${code};${modifier}~`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ss3: Record<number, string> = { 0x70: 'P', 0x71: 'Q', 0x72: 'R', 0x73: 'S' }
|
|
134
|
+
if (ss3[virtualKey]) return modifier === 1 ? `\x1bO${ss3[virtualKey]}` : `\x1b[1;${modifier}${ss3[virtualKey]}`
|
|
135
|
+
return ''
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Use a translating stdin only on a real Windows TTY; other platforms remain untouched. */
|
|
139
|
+
export function createInkInputStream(
|
|
140
|
+
input: NodeJS.ReadStream,
|
|
141
|
+
output: NodeJS.WriteStream,
|
|
142
|
+
): NodeJS.ReadStream {
|
|
143
|
+
if (process.platform !== 'win32' || !input.isTTY) return input
|
|
144
|
+
|
|
145
|
+
const decoder = new WindowsInputDecoder()
|
|
146
|
+
let modeEnabled = false
|
|
147
|
+
let pendingTimer: ReturnType<typeof setTimeout> | null = null
|
|
148
|
+
const translated = new Transform({
|
|
149
|
+
transform(chunk, _encoding, callback) {
|
|
150
|
+
if (pendingTimer) clearTimeout(pendingTimer)
|
|
151
|
+
const decoded = decoder.push(String(chunk))
|
|
152
|
+
if (decoder.hasPendingInput) {
|
|
153
|
+
// An unsupported terminal still sends a legacy standalone Esc. Give a
|
|
154
|
+
// split win32 record one event-loop beat to finish, then release it.
|
|
155
|
+
pendingTimer = setTimeout(() => {
|
|
156
|
+
pendingTimer = null
|
|
157
|
+
translated.push(decoder.flush())
|
|
158
|
+
}, 15)
|
|
159
|
+
}
|
|
160
|
+
callback(null, decoded)
|
|
161
|
+
},
|
|
162
|
+
flush(callback) {
|
|
163
|
+
if (pendingTimer) clearTimeout(pendingTimer)
|
|
164
|
+
callback(null, decoder.flush())
|
|
165
|
+
},
|
|
166
|
+
}) as Transform & Partial<NodeJS.ReadStream>
|
|
167
|
+
|
|
168
|
+
Object.defineProperty(translated, 'isTTY', { value: true })
|
|
169
|
+
Object.defineProperty(translated, 'isRaw', { get: () => input.isRaw })
|
|
170
|
+
translated.setRawMode = (enabled: boolean) => {
|
|
171
|
+
input.setRawMode?.(enabled)
|
|
172
|
+
if (enabled !== modeEnabled) {
|
|
173
|
+
output.write(enabled ? ENABLE_WIN32_INPUT_MODE : DISABLE_WIN32_INPUT_MODE)
|
|
174
|
+
modeEnabled = enabled
|
|
175
|
+
}
|
|
176
|
+
return translated as NodeJS.ReadStream
|
|
177
|
+
}
|
|
178
|
+
translated.ref = () => { input.ref(); return translated as NodeJS.ReadStream }
|
|
179
|
+
translated.unref = () => { input.unref(); return translated as NodeJS.ReadStream }
|
|
180
|
+
|
|
181
|
+
input.pipe(translated)
|
|
182
|
+
process.once('exit', () => {
|
|
183
|
+
if (modeEnabled) output.write(DISABLE_WIN32_INPUT_MODE)
|
|
184
|
+
})
|
|
185
|
+
return translated as NodeJS.ReadStream
|
|
186
|
+
}
|
package/src/main.tsx
CHANGED
|
@@ -9,5 +9,9 @@
|
|
|
9
9
|
import React from 'react'
|
|
10
10
|
import { render } from 'ink'
|
|
11
11
|
import { App } from './App.js'
|
|
12
|
+
import { createInkInputStream } from './lib/windows-input.js'
|
|
12
13
|
|
|
13
|
-
render(React.createElement(App), {
|
|
14
|
+
render(React.createElement(App), {
|
|
15
|
+
exitOnCtrlC: true,
|
|
16
|
+
stdin: createInkInputStream(process.stdin, process.stdout),
|
|
17
|
+
})
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/** AIMUX status UI + heartbeat/reconnect regression tests. */
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { EventEmitter } from 'node:events'
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import { promises as fs, existsSync } from 'node:fs'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { render } from 'ink-testing-library'
|
|
9
|
+
import { AimuxStatusLine } from '../src/components/AimuxStatus.js'
|
|
10
|
+
import { AimuxSupervisor, probeAimuxBridgeConnection, bundleArch, bundleUrl, spawnLauncher, ensureFromBundle, downloadBundleForTest, reverseConnectArgs, aimuxLogPath, bundleHealthCheckCode } from '../src/aimux.js'
|
|
11
|
+
|
|
12
|
+
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
|
13
|
+
let pass = 0, fail = 0
|
|
14
|
+
function ok(condition: boolean, message: string) {
|
|
15
|
+
if (condition) { pass += 1; console.log(` ✓ ${message}`) }
|
|
16
|
+
else { fail += 1; console.error(` ✗ ${message}`) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fakeChild(onKill: () => void): any {
|
|
20
|
+
const child: any = new EventEmitter()
|
|
21
|
+
child.pid = 12345
|
|
22
|
+
child.stdout = new EventEmitter()
|
|
23
|
+
child.stderr = new EventEmitter()
|
|
24
|
+
child.kill = () => { onKill(); return true }
|
|
25
|
+
return child
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function testStatusLine() {
|
|
29
|
+
console.log('\n[AIMUX 1] status display')
|
|
30
|
+
const { lastFrame, rerender, unmount } = render(
|
|
31
|
+
<AimuxStatusLine status={{ state: 'starting', phase: 'install', detail: '下载并安装 aimux… 48%' }} />,
|
|
32
|
+
)
|
|
33
|
+
ok((lastFrame() ?? '').includes('AIMUX · 安装') && (lastFrame() ?? '').includes('48%'), 'installation phase and progress stay visible')
|
|
34
|
+
rerender(<AimuxStatusLine status={{ state: 'failed', phase: 'retrying', detail: '心跳中断,2 秒后进行第 2 次重连…', attempt: 2 }} />)
|
|
35
|
+
ok((lastFrame() ?? '').includes('AIMUX · 重连') && (lastFrame() ?? '').includes('第 2 次重连'), 'retry phase and attempt are explicit')
|
|
36
|
+
unmount()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function testProbeContract() {
|
|
40
|
+
console.log('\n[AIMUX 2] bridge heartbeat contract')
|
|
41
|
+
const realFetch = globalThis.fetch
|
|
42
|
+
let requestedUrl = '', auth = ''
|
|
43
|
+
globalThis.fetch = (async (input: any, init?: RequestInit) => {
|
|
44
|
+
requestedUrl = String(input)
|
|
45
|
+
auth = String((init?.headers as Record<string, string>)?.Authorization ?? '')
|
|
46
|
+
return new Response(JSON.stringify({ identifier: 'tui-test', event_stream_connected: true }), { status: 200 })
|
|
47
|
+
}) as typeof fetch
|
|
48
|
+
try {
|
|
49
|
+
const connected = await probeAimuxBridgeConnection('https://mobius.test/', 'jwt-test', 'tui-test', 100)
|
|
50
|
+
ok(connected, 'heartbeat accepts only an active event stream for this identifier')
|
|
51
|
+
ok(requestedUrl.endsWith('/aimux_bridge/api/remotes/tui-test/connection'), 'heartbeat calls the bridge connection endpoint')
|
|
52
|
+
ok(auth === 'Bearer jwt-test', 'heartbeat carries the Mobius JWT')
|
|
53
|
+
} finally { globalThis.fetch = realFetch }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function testAutomaticReconnect() {
|
|
57
|
+
console.log('\n[AIMUX 3] heartbeat-triggered reconnect')
|
|
58
|
+
const statuses: string[] = []
|
|
59
|
+
let probes = 0, spawns = 0, kills = 0
|
|
60
|
+
const supervisor = new AimuxSupervisor({
|
|
61
|
+
server: 'https://mobius.test', token: 'jwt-test', identifier: 'tui-test',
|
|
62
|
+
heartbeatIntervalMs: 5, heartbeatFailureThreshold: 2, retryBaseMs: 5,
|
|
63
|
+
probeConnection: async () => { probes += 1; return probes >= 3 },
|
|
64
|
+
spawnProcess: () => { spawns += 1; return fakeChild(() => { kills += 1 }) },
|
|
65
|
+
onStatus: status => statuses.push(`${status.state}:${status.phase}:${status.detail}`),
|
|
66
|
+
})
|
|
67
|
+
supervisor.start()
|
|
68
|
+
for (let i = 0; i < 30 && !statuses.some(s => s.startsWith('connected:')); i += 1) await delay(5)
|
|
69
|
+
ok(kills >= 1, 'two failed heartbeats terminate the stale AIMUX process')
|
|
70
|
+
ok(spawns >= 2, 'supervisor starts a fresh AIMUX process after heartbeat loss')
|
|
71
|
+
ok(statuses.some(s => s.includes('第 1 次重连')), 'reconnect status reports its retry attempt')
|
|
72
|
+
ok(statuses.some(s => s.startsWith('connected:connected:心跳正常')), 'a later successful heartbeat restores connected state')
|
|
73
|
+
await supervisor.stop()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function testBundleArchAndUrl() {
|
|
77
|
+
console.log('\n[AIMUX 4] Plan B bundle arch / url')
|
|
78
|
+
const arch = bundleArch()
|
|
79
|
+
ok(arch === 'linux-x64' || arch === 'win-x64' || arch === 'mac-x64', `bundleArch returns a supported arch on this host (${arch})`)
|
|
80
|
+
const before = bundleUrl('linux-x64')
|
|
81
|
+
ok(before.includes('mobius-python-linux-x64-v2') && before.endsWith('.zip'), 'bundleUrl follows the fixed filename pattern')
|
|
82
|
+
const saved = process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL
|
|
83
|
+
process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL = 'https://example.test/cdn/'
|
|
84
|
+
try {
|
|
85
|
+
ok(bundleUrl('win-x64') === 'https://example.test/cdn/mobius-python-win-x64-v2.zip', 'MOBIUS_TUI_PYTHON_BUNDLE_URL overrides the CDN base and trims trailing slash')
|
|
86
|
+
} finally { if (saved === undefined) delete process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL; else process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL = saved }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function testPersistentProcessLog() {
|
|
90
|
+
console.log('\n[AIMUX 9] persistent process diagnostics')
|
|
91
|
+
const home = await fs.mkdtemp(path.join(os.tmpdir(), 'mobius-tui-aimux-log-'))
|
|
92
|
+
const savedHome = process.env.MOBIUS_TUI_HOME
|
|
93
|
+
process.env.MOBIUS_TUI_HOME = home
|
|
94
|
+
const statuses: string[] = []
|
|
95
|
+
let childRef: any
|
|
96
|
+
const supervisor = new AimuxSupervisor({
|
|
97
|
+
server: 'https://mobius.test', token: 'secret-token', identifier: 'tui-log',
|
|
98
|
+
retryBaseMs: 100_000,
|
|
99
|
+
probeConnection: async () => true,
|
|
100
|
+
spawnProcess: () => {
|
|
101
|
+
childRef = fakeChild(() => {})
|
|
102
|
+
return childRef
|
|
103
|
+
},
|
|
104
|
+
onStatus: status => statuses.push(status.detail || ''),
|
|
105
|
+
})
|
|
106
|
+
supervisor.start()
|
|
107
|
+
childRef.stderr.emit('data', Buffer.from('Traceback\n File "site-packages/loguru/_ctime_functions.py", line 7\nImportError: win32_setctime missing\n'))
|
|
108
|
+
childRef.emit('exit', 1)
|
|
109
|
+
await delay(40)
|
|
110
|
+
const log = await fs.readFile(aimuxLogPath(), 'utf8')
|
|
111
|
+
ok(log.includes('win32_setctime missing') && log.includes('AIMUX exit code=1'), 'AIMUX stdout/stderr and exit code are persisted')
|
|
112
|
+
ok(log.includes('_ctime_functions.py') && !log.includes('secret-token'), 'diagnostic log keeps traceback context without JWT')
|
|
113
|
+
ok(statuses.some(s => s.includes('日志:') && s.includes('aimux.log')), 'failure status points to the persistent log path')
|
|
114
|
+
await supervisor.stop()
|
|
115
|
+
if (savedHome === undefined) delete process.env.MOBIUS_TUI_HOME; else process.env.MOBIUS_TUI_HOME = savedHome
|
|
116
|
+
await fs.rm(home, { recursive: true, force: true })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function captureStdout(child: ReturnType<typeof spawn>): Promise<string> {
|
|
120
|
+
let out = ''
|
|
121
|
+
child.stdout?.on('data', d => { out += d.toString() })
|
|
122
|
+
return new Promise(resolve => child.on('close', () => resolve(out)))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function testSpawnLauncher() {
|
|
126
|
+
console.log('\n[AIMUX 5] Plan B spawnLauncher routing')
|
|
127
|
+
// exe launcher: spawn the binary directly with the given args
|
|
128
|
+
let out = await captureStdout(spawnLauncher({ kind: 'exe', path: '/bin/echo' }, ['HELLO', 'arg']))
|
|
129
|
+
ok(out.trim() === 'HELLO arg', `exe launcher runs the aimux binary directly (got: ${out.trim()})`)
|
|
130
|
+
// module launcher: inject `-m aimux` in front (so `<python> -m aimux ...`)
|
|
131
|
+
out = await captureStdout(spawnLauncher({ kind: 'module', python: '/bin/echo' }, ['reverse', 'connect']))
|
|
132
|
+
ok(out.trim() === '-m aimux reverse connect', `module launcher prepends -m aimux (got: ${out.trim()})`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function testReverseConnectArgs() {
|
|
136
|
+
console.log('\n[AIMUX 6] reverse connect Windows shell visibility')
|
|
137
|
+
const win = reverseConnectArgs('https://mobius.test/', 'tui-win', 'jwt-test', 'win32')
|
|
138
|
+
const linux = reverseConnectArgs('https://mobius.test/', 'tui-linux', 'jwt-test', 'linux')
|
|
139
|
+
ok(win.includes('--silent-shell'), 'Windows reverse connection always requests hidden command shells')
|
|
140
|
+
ok(!linux.includes('--silent-shell'), 'non-Windows reverse connection does not receive the Windows-only flag')
|
|
141
|
+
ok(win[2] === 'https://mobius.test/aimux_bridge', 'reverse connection normalizes the bridge URL')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function testBundleHealthCheck() {
|
|
145
|
+
console.log('\n[AIMUX 6b] bundle dependency health check')
|
|
146
|
+
const win = bundleHealthCheckCode('win32')
|
|
147
|
+
const linux = bundleHealthCheckCode('linux')
|
|
148
|
+
ok(win.includes('aimux.bridge_client') && win.includes('win32_setctime'), 'Windows bundle probe imports the real bridge path and its platform dependency')
|
|
149
|
+
ok(win.includes("aimux.__version__ == '0.1.21'"), 'bundle probe rejects stale AIMUX versions')
|
|
150
|
+
ok(!linux.includes('win32_setctime'), 'non-Windows bundle probe does not require the Windows-only package')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function testEnsureFromBundleReady() {
|
|
154
|
+
console.log('\n[AIMUX 7] Plan B ensureFromBundle fast-path (bundle already extracted)')
|
|
155
|
+
const home = await fs.mkdtemp(path.join(os.tmpdir(), 'mobius-tui-bundle-'))
|
|
156
|
+
const savedHome = process.env.MOBIUS_TUI_HOME
|
|
157
|
+
process.env.MOBIUS_TUI_HOME = home
|
|
158
|
+
// 放一个"假 python": 任何 `-c import aimux` 都返回 0 → bundleReady() 为真
|
|
159
|
+
const fakePy = path.join(home, 'python-bundle', 'python', 'bin', 'python3')
|
|
160
|
+
await fs.mkdir(path.dirname(fakePy), { recursive: true })
|
|
161
|
+
await fs.writeFile(fakePy, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
|
|
162
|
+
try {
|
|
163
|
+
const r = await ensureFromBundle()
|
|
164
|
+
ok(r.ok === true && r.launcher?.kind === 'module', 'ensureFromBundle short-circuits when the bundle is already present')
|
|
165
|
+
ok(r.launcher?.kind === 'module' && r.launcher.python.endsWith(path.join('python-bundle', 'python', 'bin', 'python3')), 'launcher points at the bundled python')
|
|
166
|
+
ok(!existsSync(path.join(home, 'python-bundle-v1.zip.tmp')), 'no download tmp is left behind on the fast-path')
|
|
167
|
+
} finally { if (savedHome === undefined) delete process.env.MOBIUS_TUI_HOME; else process.env.MOBIUS_TUI_HOME = savedHome; await fs.rm(home, { recursive: true, force: true }) }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function testDownloadBundleStream() {
|
|
171
|
+
console.log('\n[AIMUX 8] Plan B downloadBundle streams body to file + reports progress')
|
|
172
|
+
const home = await fs.mkdtemp(path.join(os.tmpdir(), 'mobius-tui-dl-'))
|
|
173
|
+
const savedHome = process.env.MOBIUS_TUI_HOME
|
|
174
|
+
process.env.MOBIUS_TUI_HOME = home
|
|
175
|
+
const realFetch = globalThis.fetch
|
|
176
|
+
const payload = Buffer.from(Array.from({ length: 64 * 1024 }, (_, i) => i & 0xff))
|
|
177
|
+
globalThis.fetch = (async () => new Response(payload as any, {
|
|
178
|
+
status: 200, headers: { 'content-length': String(payload.length) },
|
|
179
|
+
})) as typeof fetch
|
|
180
|
+
let progressCalls = 0
|
|
181
|
+
try {
|
|
182
|
+
const r = await downloadBundleForTest('linux-x64', () => { progressCalls += 1 })
|
|
183
|
+
ok(r.ok === true && !!r.zipPath, 'downloadBundle writes the streamed body to a zip tmp')
|
|
184
|
+
const written = await fs.readFile(r.zipPath!)
|
|
185
|
+
ok(written.length === payload.length && written[0] === 0 && written[65535] === 255, 'downloaded bytes match the streamed payload')
|
|
186
|
+
ok(progressCalls > 0, 'progress callback fires during streaming download')
|
|
187
|
+
await fs.unlink(r.zipPath!)
|
|
188
|
+
} finally {
|
|
189
|
+
globalThis.fetch = realFetch
|
|
190
|
+
if (savedHome === undefined) delete process.env.MOBIUS_TUI_HOME; else process.env.MOBIUS_TUI_HOME = savedHome
|
|
191
|
+
await fs.rm(home, { recursive: true, force: true })
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function main() {
|
|
196
|
+
await testStatusLine()
|
|
197
|
+
await testProbeContract()
|
|
198
|
+
await testAutomaticReconnect()
|
|
199
|
+
await testBundleArchAndUrl()
|
|
200
|
+
await testSpawnLauncher()
|
|
201
|
+
testReverseConnectArgs()
|
|
202
|
+
testBundleHealthCheck()
|
|
203
|
+
await testEnsureFromBundleReady()
|
|
204
|
+
await testDownloadBundleStream()
|
|
205
|
+
await testPersistentProcessLog()
|
|
206
|
+
console.log(`\n==== AIMUX RESULT: ${pass} passed, ${fail} failed ====\n`)
|
|
207
|
+
process.exit(fail === 0 ? 0 : 1)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
main().catch(error => { console.error('FATAL', error); process.exit(2) })
|