@mobius-os/mobius 0.3.27 → 0.3.34
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/README.md +7 -0
- package/install.ps1 +265 -0
- package/package.json +24 -7
- package/scripts/build-python-bundles.sh +129 -0
- package/src/App.tsx +2 -2
- package/src/components/Chat.tsx +158 -341
- package/src/components/ConfigFlow.tsx +2 -0
- package/src/components/primitives.tsx +134 -7
- package/src/lib/entry-view.ts +10 -5
- package/src/lib/screen-text.ts +68 -57
- package/src/lib/transcript-viewport.ts +162 -0
- package/src/lib/windows-input.ts +186 -0
- package/src/main.tsx +5 -1
- package/src/version.ts +21 -0
- 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 +118 -0
- package/tests/scroll.test.tsx +253 -0
- package/tests/selection.test.tsx +219 -0
- package/tests/ui.test.tsx +901 -0
- package/tests/viewport.test.ts +83 -0
- package/tsconfig.json +19 -0
- package/uninstall.ps1 +144 -0
|
@@ -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
|
+
})
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TUI package version has one source of truth: package.json.
|
|
3
|
+
*
|
|
4
|
+
* Keep all runtime/UI consumers behind this module so a release cannot show
|
|
5
|
+
* a stale hard-coded version while the npm package has already been bumped.
|
|
6
|
+
*/
|
|
7
|
+
import { createRequire } from 'node:module'
|
|
8
|
+
|
|
9
|
+
interface TuiPackageMetadata {
|
|
10
|
+
name?: string
|
|
11
|
+
version?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const packageJson = createRequire(import.meta.url)('../package.json') as TuiPackageMetadata
|
|
15
|
+
|
|
16
|
+
if (!packageJson.version) {
|
|
17
|
+
throw new Error('TUI package.json is missing a version')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const TUI_VERSION = packageJson.version
|
|
21
|
+
export const TUI_PACKAGE_NAME = packageJson.name ?? '@mobius-os/mobius'
|
|
@@ -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) })
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow test — drive the WHOLE App as a user would, end to end, through the real
|
|
3
|
+
* Ink screens (login → prep wizard → chat → /clear → /resume), against a mocked
|
|
4
|
+
* backend. Captures rendered frames at each milestone as evidence.
|
|
5
|
+
*
|
|
6
|
+
* Run: npm run test:flow
|
|
7
|
+
*/
|
|
8
|
+
import os from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
|
|
12
|
+
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'mobius-tui-flow-'))
|
|
13
|
+
process.env.MOBIUS_TUI_HOME = TMP_HOME
|
|
14
|
+
|
|
15
|
+
import React from 'react'
|
|
16
|
+
import { render } from 'ink-testing-library'
|
|
17
|
+
import { App } from '../src/App.js'
|
|
18
|
+
|
|
19
|
+
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
|
20
|
+
const RS: any = (globalThis as any).ReadableStream
|
|
21
|
+
const enc = new TextEncoder()
|
|
22
|
+
let sseController: any = null
|
|
23
|
+
function emit(ev: string, data: Record<string, unknown>) {
|
|
24
|
+
sseController?.enqueue(enc.encode(`event: ${ev}\ndata: ${JSON.stringify({ event: ev, ...data })}\n\n`))
|
|
25
|
+
}
|
|
26
|
+
function json(body: unknown, status = 200) {
|
|
27
|
+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const snapshots: { step: string; frame: string }[] = []
|
|
31
|
+
function snap(step: string, frame: string) { snapshots.push({ step, frame: frame.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '') }) }
|
|
32
|
+
|
|
33
|
+
let pass = 0, fail = 0
|
|
34
|
+
function ok(c: boolean, m: string) { c ? (pass++, console.log(` ✓ ${m}`)) : (fail++, console.error(` ✗ ${m}`)) }
|
|
35
|
+
|
|
36
|
+
// ── mocked backend (precise URL matchers — substring overlaps broke an earlier draft) ─
|
|
37
|
+
const PID = 'proj-1', IID = 'issue-1', SID = 'sess-1'
|
|
38
|
+
function mockFetch(url: string, init?: RequestInit): Response {
|
|
39
|
+
// SSE
|
|
40
|
+
if (url.includes('/events')) {
|
|
41
|
+
return new Response(new RS({ start(c: any) { sseController = c; c.enqueue(enc.encode('event: subscribed\ndata: {"event":"subscribed"}\n\n')) } }),
|
|
42
|
+
{ status: 200, headers: { 'content-type': 'text/event-stream' } })
|
|
43
|
+
}
|
|
44
|
+
const method = init?.method ?? 'GET'
|
|
45
|
+
// auth
|
|
46
|
+
if (url.endsWith('/api/auth/config')) return json({ password_required: false })
|
|
47
|
+
if (url.endsWith('/api/auth/me')) return json({ id: 'tester', display_name: 'Test User', role: 'admin', work_dir: '/tmp' })
|
|
48
|
+
if (url.endsWith('/api/auth/login')) return json({ token: 'mock-jwt-token', user: { id: 'tester', display_name: 'Test User', role: 'admin' } })
|
|
49
|
+
if (url.includes('/aimux_bridge/api/remotes/') && url.endsWith('/connection')) {
|
|
50
|
+
const match = url.match(/remotes\/([^/]+)\/connection/)
|
|
51
|
+
return json({ identifier: match ? decodeURIComponent(match[1]) : 'tui-test', event_stream_connected: true })
|
|
52
|
+
}
|
|
53
|
+
// sessions (must be checked before issues/projects — the session URL contains /issues too)
|
|
54
|
+
if (url.includes('/sessions') && url.includes('/issues') && method === 'POST') return json({ session_id: SID }) // create session
|
|
55
|
+
if (url.includes('/sessions') && url.includes('/issues') && method === 'GET') { // list sessions (resume)
|
|
56
|
+
return json([{ session_id: SID, name: '历史会话一', last_active: new Date(Date.now() - 3600_000).toISOString(), message_count: 5, model: 'codex', issue_title: '命令行任务' }])
|
|
57
|
+
}
|
|
58
|
+
if (url.endsWith('/messages') && method === 'POST') {
|
|
59
|
+
setTimeout(() => {
|
|
60
|
+
emit('typing', { active: true })
|
|
61
|
+
emit('jsonl_entry', { session_id: SID, entry: { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: '已收到,这是来自 TUI 的回复。' }] } } })
|
|
62
|
+
emit('typing', { active: false })
|
|
63
|
+
}, 200)
|
|
64
|
+
return json({ ok: true, session_id: SID, turn_number: 1 })
|
|
65
|
+
}
|
|
66
|
+
if (url.endsWith(`/api/sessions/${SID}/status`)) {
|
|
67
|
+
return json({ session_id: SID, alive: true, working: false })
|
|
68
|
+
}
|
|
69
|
+
// issues
|
|
70
|
+
if (url.includes('/api/projects/') && url.includes('/issues') && method === 'POST') return json({ id: IID, project_id: PID, title: '命令行任务' }) // create issue
|
|
71
|
+
if (url.includes('/api/projects/') && url.includes('/issues') && method === 'GET') return json([{ id: IID, project_id: PID, title: '命令行任务' }]) // list issues
|
|
72
|
+
// projects
|
|
73
|
+
if (url.includes('/api/projects') && method === 'GET') return json([{ id: PID, name: '已有项目甲' }]) // list projects
|
|
74
|
+
if (url.endsWith('/api/projects') && method === 'POST') return json({ id: PID, name: '测试项目PTY' }) // create project (exact)
|
|
75
|
+
// preference lookups
|
|
76
|
+
if (url.includes('/sessions/model-options')) return json([{ key: 'codex', label: 'GPT-5.5', title: 'GPT-5.5', sub: 'Codex', backend: 'tmux-codex' }])
|
|
77
|
+
if (url.includes('/sessions/default-model')) return json({ model: 'codex' })
|
|
78
|
+
if (url.includes('/skills')) return json([])
|
|
79
|
+
if (url.includes('/memories')) return json([])
|
|
80
|
+
return json({ error: `unmocked ${method} ${url}` }, 404)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function waitFor(lastFrame: () => string | undefined, needle: string, timeoutMs = 4000) {
|
|
84
|
+
for (let i = 0; i < timeoutMs / 50; i++) {
|
|
85
|
+
if ((lastFrame() ?? '').includes(needle)) return true
|
|
86
|
+
await delay(50)
|
|
87
|
+
}
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function main() {
|
|
92
|
+
// Pre-seed login so App auto-logs in (login form itself is covered in ui.test).
|
|
93
|
+
fs.writeFileSync(path.join(TMP_HOME, 'login.json'), JSON.stringify({
|
|
94
|
+
server: 'http://mock.local', username: 'tester', token: 'mock-jwt-token',
|
|
95
|
+
user: { id: 'tester', display_name: 'Test User', role: 'admin' },
|
|
96
|
+
}))
|
|
97
|
+
const realFetch = globalThis.fetch
|
|
98
|
+
globalThis.fetch = ((u: any, init?: any) => mockFetch(String(u), init)) as unknown as typeof fetch
|
|
99
|
+
process.env.MOBIUS_TUI_DEBUG = '1'
|
|
100
|
+
|
|
101
|
+
console.log('\n[FLOW] full App drive (mocked backend)\n')
|
|
102
|
+
const { stdin, lastFrame, unmount } = render(React.createElement(App))
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
// ── prep: project picker (auto-logged in) ────────────────────────────────
|
|
106
|
+
ok(await waitFor(lastFrame, '选择当前路径的绑定项目'), 'booted into project picker')
|
|
107
|
+
// pick "➕ 创建新项目" (active index 0) → name wizard
|
|
108
|
+
stdin.write('\r'); await delay(120)
|
|
109
|
+
ok(await waitFor(lastFrame, '项目名称'), 'project create wizard opened')
|
|
110
|
+
stdin.write('测试项目PTY'); await delay(120)
|
|
111
|
+
stdin.write('\r'); await delay(300) // submit project name
|
|
112
|
+
snap('1-prep-project-created', lastFrame() ?? '')
|
|
113
|
+
|
|
114
|
+
// ── prep: issue picker (no issues → create) ──────────────────────────────
|
|
115
|
+
ok(await waitFor(lastFrame, '创建新任务'), 'issue picker shown')
|
|
116
|
+
stdin.write('\r'); await delay(120) // → create-name
|
|
117
|
+
ok(await waitFor(lastFrame, '输入任务名称'), 'issue name wizard opened')
|
|
118
|
+
stdin.write('\x1b'); await delay(120) // Esc → issue list
|
|
119
|
+
ok(await waitFor(lastFrame, '选择任务(Issue)'), 'Esc returns from issue name wizard to issue list')
|
|
120
|
+
stdin.write('\r'); await delay(120) // → create-name again
|
|
121
|
+
ok(await waitFor(lastFrame, '输入任务名称'), 'issue name wizard can be reopened after Esc')
|
|
122
|
+
stdin.write('命令行任务'); await delay(120)
|
|
123
|
+
stdin.write('\r'); await delay(300) // create issue (worktree off) → model
|
|
124
|
+
|
|
125
|
+
// ── prep: preferences ────────────────────────────────────────────────────
|
|
126
|
+
ok(await waitFor(lastFrame, '选择模型'), 'model picker shown')
|
|
127
|
+
stdin.write('\r'); await delay(250) // pick codex
|
|
128
|
+
ok(await waitFor(lastFrame, '选择回复语言'), 'language picker shown')
|
|
129
|
+
stdin.write('\r'); await delay(400) // zh; skills+memories empty → auto-skip
|
|
130
|
+
|
|
131
|
+
// ── chat ─────────────────────────────────────────────────────────────────
|
|
132
|
+
ok(await waitFor(lastFrame, '输入问题'), 'entered chat (preferences complete)')
|
|
133
|
+
snap('2-chat-ready', lastFrame() ?? '')
|
|
134
|
+
|
|
135
|
+
// send a message — expect streamed assistant reply
|
|
136
|
+
stdin.write('你好,请回复一句话'); await delay(120)
|
|
137
|
+
stdin.write('\r')
|
|
138
|
+
ok(await waitFor(lastFrame, '已收到', 6000), 'assistant reply streamed into transcript')
|
|
139
|
+
await delay(300)
|
|
140
|
+
snap('3-chat-after-reply', lastFrame() ?? '')
|
|
141
|
+
|
|
142
|
+
// ── /clear ───────────────────────────────────────────────────────────────
|
|
143
|
+
stdin.write('/clear'); await delay(120)
|
|
144
|
+
stdin.write('\r')
|
|
145
|
+
ok(await waitFor(lastFrame, '输入问题'), '/clear reset to a fresh chat')
|
|
146
|
+
snap('4-after-clear', lastFrame() ?? '')
|
|
147
|
+
|
|
148
|
+
// ── /resume ──────────────────────────────────────────────────────────────
|
|
149
|
+
// /resume — wait for the post-/clear remount to settle, then type slowly.
|
|
150
|
+
await delay(500)
|
|
151
|
+
stdin.write('/resume'); await delay(300)
|
|
152
|
+
stdin.write('\r')
|
|
153
|
+
await delay(500)
|
|
154
|
+
snap('4b-resume-picker', lastFrame() ?? '')
|
|
155
|
+
ok(await waitFor(lastFrame, '恢复历史会话'), '/resume picker opened')
|
|
156
|
+
ok((lastFrame() ?? '').includes('历史会话一'), 'resume list shows the past session')
|
|
157
|
+
stdin.write('\r'); await delay(500) // pick session → reconnect SSE
|
|
158
|
+
ok(await waitFor(lastFrame, '输入问题'), 'resumed into chat')
|
|
159
|
+
snap('5-after-resume', lastFrame() ?? '')
|
|
160
|
+
|
|
161
|
+
// ── /config: full reconfigure (project → issue → model) ──────────────
|
|
162
|
+
// Unlike /model, /config walks through project, issue, AND model steps.
|
|
163
|
+
await delay(400)
|
|
164
|
+
stdin.write('/config'); await delay(300)
|
|
165
|
+
stdin.write('\r')
|
|
166
|
+
ok(await waitFor(lastFrame, '重新配置'), '/config opens the full reconfig flow')
|
|
167
|
+
ok(await waitFor(lastFrame, '选择项目'), '/config shows project picker first')
|
|
168
|
+
// Pick the first project (created above).
|
|
169
|
+
stdin.write('\r'); await delay(400)
|
|
170
|
+
ok(await waitFor(lastFrame, '选择任务'), '/config shows issue picker after project')
|
|
171
|
+
// Pick the first issue.
|
|
172
|
+
stdin.write('\r'); await delay(400)
|
|
173
|
+
ok(await waitFor(lastFrame, '选择模型'), '/config shows model picker after issue')
|
|
174
|
+
ok(await waitFor(lastFrame, 'GPT-5.5'), '/config model list rendered')
|
|
175
|
+
stdin.write('\r'); await delay(700) // pick codex → create session
|
|
176
|
+
ok(await waitFor(lastFrame, '输入问题'), '/config creates a fresh session and returns to chat')
|
|
177
|
+
ok((lastFrame() ?? '').includes('?session=sess-1'), 'reconfigured chat is attached to the new session')
|
|
178
|
+
snap('6-after-config', lastFrame() ?? '')
|
|
179
|
+
|
|
180
|
+
// ── /model: swap model only, keep current task ─────────────────────────
|
|
181
|
+
// No issue/project step — the current task is kept; pick a model and App
|
|
182
|
+
// remounts Chat on the eagerly created session.
|
|
183
|
+
await delay(400)
|
|
184
|
+
stdin.write('/model'); await delay(300)
|
|
185
|
+
stdin.write('\r')
|
|
186
|
+
ok(await waitFor(lastFrame, '更换模型'), '/model opens the model picker directly')
|
|
187
|
+
ok(await waitFor(lastFrame, '选择模型'), 'model picker shown without an issue-selection step')
|
|
188
|
+
ok((lastFrame() ?? '').includes('当前任务: 命令行任务'), 'current task is kept (issue not changed)')
|
|
189
|
+
ok(await waitFor(lastFrame, 'GPT-5.5'), 'model list rendered (Select mounted)')
|
|
190
|
+
stdin.write('\r'); await delay(700) // pick codex → create session
|
|
191
|
+
ok(await waitFor(lastFrame, '输入问题'), '/model creates a fresh session and returns to chat')
|
|
192
|
+
ok((lastFrame() ?? '').includes('?session=sess-1'), '/model new session attached')
|
|
193
|
+
snap('7-after-model', lastFrame() ?? '')
|
|
194
|
+
snap('6-after-config', lastFrame() ?? '')
|
|
195
|
+
} finally {
|
|
196
|
+
unmount()
|
|
197
|
+
globalThis.fetch = realFetch
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
console.log('\n──────── captured frames ────────')
|
|
201
|
+
for (const s of snapshots) {
|
|
202
|
+
console.log(`\n── ${s.step} ──`)
|
|
203
|
+
console.log(s.frame.replace(/\n{3,}/g, '\n\n').trim())
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try { fs.rmSync(TMP_HOME, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
207
|
+
console.log(`\n==== FLOW RESULT: ${pass} passed, ${fail} failed ====\n`)
|
|
208
|
+
process.exit(fail === 0 ? 0 : 1)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
main().catch((e) => { console.error('FATAL', e); process.exit(2) })
|