@mobius-os/mobius 0.3.42 → 0.3.43

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.
@@ -1,129 +0,0 @@
1
- #!/usr/bin/env bash
2
- # 打包 TUI Plan B 用的 "python + aimux" 离线运行时 zip(linux-x64 / win-x64 / mac-x64)。
3
- #
4
- # 基础: python-build-standalone (CPython 3.12.7, tag 20241002), 自带完整 ensurepip+pip。
5
- # aimux 及其依赖 (click/loguru/typer/rich) 全为纯 Python → linux 上一次 pip install 产出的
6
- # 代码三平台通吃。win/mac 通过 `pip install --target` 把纯 python 轮子跨装进各自 site-packages。
7
- # 跨装坑: click 在 Windows 依赖 colorama (marker platform_system==Windows), linux 上 pip 会漏,
8
- # 故 win 目标显式补 colorama。
9
- #
10
- # 产物: <DIST>/mobius-python-<arch>-v<BUNDLE_VER>.zip (zip 内根目录为 python/)
11
- # TUI 端 URL 默认指向 mobius CDN, 可用 MOBIUS_TUI_PYTHON_BUNDLE_URL 覆盖。
12
- set -euo pipefail
13
-
14
- TAG=20241002
15
- PYVER=3.12.7
16
- BUNDLE_VER=3
17
- AIMUX_VERSION=0.1.22
18
- PYPI_INDEX=https://pypi.org/simple
19
- WORK="${WORK:-/home/tianyi/python-bundles}"
20
- DIST="${DIST:-$WORK/dist}"
21
- mkdir -p "$WORK" "$DIST"
22
-
23
- base="https://github.com/astral-sh/python-build-standalone/releases/download/$TAG"
24
- # install_only_stripped = 去掉静态库 libpython.a / debug 符号, 专为分发瘦身 (运行 Python 应用无影响)
25
- declare -A URLS=(
26
- [linux-x64]="$base/cpython-${PYVER}+${TAG}-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
27
- [win-x64]="$base/cpython-${PYVER}+${TAG}-x86_64-pc-windows-msvc-shared-install_only_stripped.tar.gz"
28
- [mac-x64]="$base/cpython-${PYVER}+${TAG}-x86_64-apple-darwin-install_only_stripped.tar.gz"
29
- )
30
-
31
- # 运行 `python -m aimux` 用不到的部分: 静态库/构建脚本/idle/tk/ensurepip 内置 wheel/缓存
32
- prune() { # prune <python-root>
33
- local root=$1
34
- rm -rf "$root"/lib/python*/config-* "$root"/lib/python*/test "$root"/lib/python*/idlelib \
35
- "$root"/lib/python*/tkinter "$root"/lib/python*/turtledemo "$root"/lib/python*/ensurepip/_bundled \
36
- "$root"/lib/python*/site-packages/pip* "$root"/lib/python*/site-packages/setuptools* "$root"/lib/python*/site-packages/pkg_resources* \
37
- "$root"/Lib/config "$root"/Lib/test "$root"/Lib/idlelib "$root"/Lib/tkinter "$root"/Lib/turtledemo \
38
- "$root"/Lib/ensurepip/_bundled \
39
- 2>/dev/null || true
40
- find "$root" -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true
41
- find "$root" -name '*.pyc' -delete 2>/dev/null || true
42
- rm -f "$root"/bin/2to3* "$root"/bin/idle* "$root"/bin/pydoc* "$root"/bin/*-config \
43
- "$root"/Scripts/2to3* "$root"/Scripts/idle* "$root"/Scripts/pydoc* 2>/dev/null || true
44
- }
45
-
46
- # 也许需要代理拉 github / pypi; 命令行无代理时直接跑, 失败再换 proxychains
47
- dl() { # dl <url> <out>
48
- if command -v proxychains4 >/dev/null 2>&1 && [ "${USE_PROXY:-1}" = 1 ]; then
49
- proxychains4 -q curl -fL "$1" -o "$2"
50
- else
51
- curl -fL "$1" -o "$2"
52
- fi
53
- }
54
- uv_install_python() {
55
- if command -v uv >/dev/null 2>&1; then
56
- uv pip install --python "$LINUX_PY" --index-url "$PYPI_INDEX" "$@"
57
- else
58
- "$LINUX_PY" -m pip install "$@"
59
- fi
60
- }
61
-
62
- echo "== 1) 下载并解压 python-build-standalone =="
63
- for arch in linux-x64 win-x64 mac-x64; do
64
- if [ -x "$WORK/$arch/python/bin/python3" ] || [ -f "$WORK/$arch/python/python.exe" ]; then
65
- echo " [$arch] 已存在, 跳过下载"; continue
66
- fi
67
- echo " [$arch] 下载 ${URLS[$arch]}"
68
- dl "${URLS[$arch]}" "$WORK/$arch.tar.gz"
69
- mkdir -p "$WORK/$arch"
70
- tar -xzf "$WORK/$arch.tar.gz" -C "$WORK/$arch"
71
- done
72
-
73
- LINUX_PY="$WORK/linux-x64/python/bin/python3"
74
- echo "== 2) linux-x64: 原生 pip install aimux =="
75
- USE_PROXY=1 uv_install_python --quiet "aimux==$AIMUX_VERSION" colorama || \
76
- uv_install_python "aimux==$AIMUX_VERSION" colorama
77
- echo " 验证: $($LINUX_PY -c 'import aimux, click, loguru, typer, rich; print("linux import ok", aimux.__name__)')"
78
-
79
- echo "== 3) win-x64 / mac-x64: 跨装纯 python aimux 到各自 site-packages =="
80
- # win: click 需 colorama; mac: 不需要 colorama
81
- install_target() { # arch site_packages_dir [extra...]
82
- local arch=$1 sp=$2; shift 2
83
- echo " [$arch] pip install --target $sp aimux $*"
84
- rm -rf "$sp"/* 2>/dev/null || true # 重复构建时清旧
85
- USE_PROXY=1 uv pip install --quiet --index-url "$PYPI_INDEX" --target "$sp" "aimux==$AIMUX_VERSION" "$@" || \
86
- uv pip install --index-url "$PYPI_INDEX" --target "$sp" "aimux==$AIMUX_VERSION" "$@"
87
- echo " [$arch] site-packages:"; ls "$sp" | head -20
88
- }
89
- install_target win-x64 "$WORK/win-x64/python/Lib/site-packages" colorama win32-setctime
90
- install_target mac-x64 "$WORK/mac-x64/python/lib/python3.12/site-packages"
91
-
92
- echo "== 4) 瘦身 (删运行时用不到的 test/idle/tk/ensurepip wheel/缓存) 后打 zip =="
93
- for arch in linux-x64 win-x64 mac-x64; do prune "$WORK/$arch/python"; done
94
- # 本机无 zip 命令 → 用 python 造一个等价 -ry 的打包器: external_attr 存 st_mode,
95
- # 符号链接存为 link-target + S_IFLNK 位, extract-zip 据此还原 symlink 与可执行位。
96
- zip_py="$(mktemp).py"
97
- cat > "$zip_py" <<'PYEOF'
98
- import os, sys, stat, zipfile
99
- src, out = sys.argv[1], sys.argv[2]
100
- parent = os.path.dirname(src.rstrip('/'))
101
- def add(z, full):
102
- arc = os.path.relpath(full, parent)
103
- st = os.lstat(full)
104
- zi = zipfile.ZipInfo(arc, (1980, 1, 1, 0, 0, 0))
105
- zi.external_attr = (st.st_mode & 0xFFFF) << 16
106
- zi.create_system = 3 # unix
107
- if stat.S_ISLNK(st.st_mode):
108
- z.writestr(zi, os.readlink(full)) # 符号链接: 内容=目标路径
109
- else:
110
- with open(full, 'rb') as f: z.writestr(zi, f.read())
111
- with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:
112
- for dp, dirs, files in os.walk(src):
113
- for name in list(dirs):
114
- full = os.path.join(dp, name)
115
- if os.path.islink(full): # 目录符号链接: 存为链接, 不下钻
116
- add(z, full); dirs.remove(name)
117
- for name in files:
118
- add(z, os.path.join(dp, name))
119
- print(' ok', out)
120
- PYEOF
121
- for arch in linux-x64 win-x64 mac-x64; do
122
- out="$DIST/mobius-python-$arch-v${BUNDLE_VER}.zip"
123
- rm -f "$out"
124
- "$LINUX_PY" "$zip_py" "$WORK/$arch/python" "$out"
125
- echo " $out $(du -h "$out" | cut -f1)"
126
- done
127
- rm -f "$zip_py"
128
- echo "== 完成. 产物: =="
129
- ls -lh "$DIST"/mobius-python-*-v${BUNDLE_VER}.zip
@@ -1,241 +0,0 @@
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, pickSilentFlag, aimuxLogPath, bundleHealthCheckCode, tuiAimuxIdentifier } 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-v3') && 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-v3.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 silent flag adapts to installed aimux')
137
- // Old aimux (PyPI 0.1.20 / cached bundle 0.1.21): advertises only --silent-shell.
138
- // Must NOT send the newer --slient-v2 it doesn't know — that is the crash-loop bug.
139
- const oldWin = reverseConnectArgs('https://mobius.test/', 'tui-win', 'jwt-test', 'win32', '--silent-shell')
140
- ok(oldWin.includes('--silent-shell'), 'old aimux gets the --silent-shell flag it supports')
141
- ok(!oldWin.includes('--slient-v2') && !oldWin.includes('--silent-v2'), 'old aimux never gets the unsupported v2 flag (no crash-loop)')
142
- // New aimux (0.1.22+): probe resolves the correctly-spelled --silent-v2.
143
- const newWin = reverseConnectArgs('https://mobius.test/', 'tui-win', 'jwt-test', 'win32', '--silent-v2')
144
- ok(newWin.includes('--silent-v2'), 'new aimux gets the no-console v2 flag')
145
- // Probe found nothing supported (or pre-probe default): send nothing, stay alive.
146
- const bareWin = reverseConnectArgs('https://mobius.test/', 'tui-win', 'jwt-test', 'win32', null)
147
- ok(!bareWin.some(a => a === '--slient-v2' || a === '--silent-v2' || a === '--silent-shell'), 'unknown aimux gets no silent flag rather than crash-looping')
148
- // Off-Windows: never any silent flag, regardless of what the probe found.
149
- const linux = reverseConnectArgs('https://mobius.test/', 'tui-linux', 'jwt-test', 'linux', '--silent-v2')
150
- ok(!linux.includes('--silent-v2') && !linux.includes('--silent-shell'), 'non-Windows never receives a Windows-only flag')
151
- ok(oldWin[2] === 'https://mobius.test/aimux_bridge', 'reverse connection normalizes the bridge URL')
152
- }
153
-
154
- function testPickSilentFlag() {
155
- console.log('\n[AIMUX 6b] pickSilentFlag reads what aimux advertises')
156
- ok(pickSilentFlag(' --slient-v2, --silent-v2 Hide console.', 'win32') === '--silent-v2', 'prefers correct --silent-v2 spelling when both aliases are advertised')
157
- ok(pickSilentFlag(' --slient-v2 Hide console.', 'win32') === '--slient-v2', 'falls back to the historical --slient-v2 alias')
158
- ok(pickSilentFlag(' --silent-shell Hide console.', 'win32') === '--silent-shell', 'old aimux advertising only --silent-shell')
159
- ok(pickSilentFlag('Usage: aimux reverse connect ...', 'win32') === null, 'unsupported aimux → null (send nothing, avoid crash-loop)')
160
- ok(pickSilentFlag(' --silent-v2 Hide console.', 'linux') === null, 'off-Windows → always null')
161
- }
162
-
163
- function testAimuxIdentifierScopesWorkspace() {
164
- console.log('\n[AIMUX 6a] reverse client identifier workspace isolation')
165
- const first = tuiAimuxIdentifier('same-host', '/work/project-a')
166
- const firstAgain = tuiAimuxIdentifier('same-host', '/work/project-a')
167
- const second = tuiAimuxIdentifier('same-host', '/work/project-b')
168
- ok(first === firstAgain, 'identifier is stable for the same host and workspace')
169
- ok(first !== second, 'different workspaces on one host do not replace each other')
170
- ok(/^tui-same-host-[a-f0-9]{10}$/.test(first), 'identifier remains bridge-safe and recognizable')
171
- }
172
-
173
- function testBundleHealthCheck() {
174
- console.log('\n[AIMUX 6b] bundle dependency health check')
175
- const win = bundleHealthCheckCode('win32')
176
- const linux = bundleHealthCheckCode('linux')
177
- ok(win.includes('aimux.bridge_client') && win.includes('win32_setctime'), 'Windows bundle probe imports the real bridge path and its platform dependency')
178
- ok(win.includes("aimux.__version__ == '0.1.23'"), 'bundle probe rejects stale AIMUX versions')
179
- ok(!linux.includes('win32_setctime'), 'non-Windows bundle probe does not require the Windows-only package')
180
- }
181
-
182
- async function testEnsureFromBundleReady() {
183
- console.log('\n[AIMUX 7] Plan B ensureFromBundle fast-path (bundle already extracted)')
184
- const home = await fs.mkdtemp(path.join(os.tmpdir(), 'mobius-tui-bundle-'))
185
- const savedHome = process.env.MOBIUS_TUI_HOME
186
- process.env.MOBIUS_TUI_HOME = home
187
- // 放一个"假 python": 任何 `-c import aimux` 都返回 0 → bundleReady() 为真
188
- const fakePy = path.join(home, 'python-bundle', 'python', 'bin', 'python3')
189
- await fs.mkdir(path.dirname(fakePy), { recursive: true })
190
- await fs.writeFile(fakePy, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
191
- try {
192
- const r = await ensureFromBundle()
193
- ok(r.ok === true && r.launcher?.kind === 'module', 'ensureFromBundle short-circuits when the bundle is already present')
194
- ok(r.launcher?.kind === 'module' && r.launcher.python.endsWith(path.join('python-bundle', 'python', 'bin', 'python3')), 'launcher points at the bundled python')
195
- ok(!existsSync(path.join(home, 'python-bundle-v1.zip.tmp')), 'no download tmp is left behind on the fast-path')
196
- } 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 }) }
197
- }
198
-
199
- async function testDownloadBundleStream() {
200
- console.log('\n[AIMUX 8] Plan B downloadBundle streams body to file + reports progress')
201
- const home = await fs.mkdtemp(path.join(os.tmpdir(), 'mobius-tui-dl-'))
202
- const savedHome = process.env.MOBIUS_TUI_HOME
203
- process.env.MOBIUS_TUI_HOME = home
204
- const realFetch = globalThis.fetch
205
- const payload = Buffer.from(Array.from({ length: 64 * 1024 }, (_, i) => i & 0xff))
206
- globalThis.fetch = (async () => new Response(payload as any, {
207
- status: 200, headers: { 'content-length': String(payload.length) },
208
- })) as typeof fetch
209
- let progressCalls = 0
210
- try {
211
- const r = await downloadBundleForTest('linux-x64', () => { progressCalls += 1 })
212
- ok(r.ok === true && !!r.zipPath, 'downloadBundle writes the streamed body to a zip tmp')
213
- const written = await fs.readFile(r.zipPath!)
214
- ok(written.length === payload.length && written[0] === 0 && written[65535] === 255, 'downloaded bytes match the streamed payload')
215
- ok(progressCalls > 0, 'progress callback fires during streaming download')
216
- await fs.unlink(r.zipPath!)
217
- } finally {
218
- globalThis.fetch = realFetch
219
- if (savedHome === undefined) delete process.env.MOBIUS_TUI_HOME; else process.env.MOBIUS_TUI_HOME = savedHome
220
- await fs.rm(home, { recursive: true, force: true })
221
- }
222
- }
223
-
224
- async function main() {
225
- await testStatusLine()
226
- await testProbeContract()
227
- await testAutomaticReconnect()
228
- await testBundleArchAndUrl()
229
- await testSpawnLauncher()
230
- testReverseConnectArgs()
231
- testPickSilentFlag()
232
- testAimuxIdentifierScopesWorkspace()
233
- testBundleHealthCheck()
234
- await testEnsureFromBundleReady()
235
- await testDownloadBundleStream()
236
- await testPersistentProcessLog()
237
- console.log(`\n==== AIMUX RESULT: ${pass} passed, ${fail} failed ====\n`)
238
- process.exit(fail === 0 ? 0 : 1)
239
- }
240
-
241
- main().catch(error => { console.error('FATAL', error); process.exit(2) })
@@ -1,253 +0,0 @@
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
- let lastMessageBody: any = null
39
- function mockFetch(url: string, init?: RequestInit): Response {
40
- // SSE
41
- if (url.includes('/events')) {
42
- return new Response(new RS({ start(c: any) { sseController = c; c.enqueue(enc.encode('event: subscribed\ndata: {"event":"subscribed"}\n\n')) } }),
43
- { status: 200, headers: { 'content-type': 'text/event-stream' } })
44
- }
45
- const method = init?.method ?? 'GET'
46
- // auth
47
- if (url.endsWith('/api/auth/config')) return json({ password_required: false })
48
- if (url.endsWith('/api/auth/me')) return json({ id: 'tester', display_name: 'Test User', role: 'admin', work_dir: '/tmp' })
49
- if (url.endsWith('/api/auth/login')) return json({ token: 'mock-jwt-token', user: { id: 'tester', display_name: 'Test User', role: 'admin' } })
50
- if (url.includes('/aimux_bridge/api/remotes/') && url.endsWith('/connection')) {
51
- const match = url.match(/remotes\/([^/]+)\/connection/)
52
- return json({ identifier: match ? decodeURIComponent(match[1]) : 'tui-test', event_stream_connected: true })
53
- }
54
- // sessions (must be checked before issues/projects — the session URL contains /issues too)
55
- if (url.includes('/sessions') && url.includes('/issues') && method === 'POST') return json({ session_id: SID }) // create session
56
- if (url.includes('/sessions') && url.includes('/issues') && method === 'GET') { // list sessions (resume)
57
- return json([{ session_id: SID, name: '历史会话一', last_active: new Date(Date.now() - 3600_000).toISOString(), message_count: 5, model: 'codex', issue_title: '命令行任务' }])
58
- }
59
- if (url.endsWith('/messages') && method === 'POST') {
60
- lastMessageBody = JSON.parse(String(init?.body || '{}'))
61
- // /compact turns come back as claude-code local-command artifacts (command
62
- // echo + completion stdout) instead of an assistant reply.
63
- if (String(lastMessageBody?.content || '').trim() === '/compact') {
64
- setTimeout(() => {
65
- emit('typing', { active: true })
66
- emit('jsonl_entry', { session_id: SID, entry: { type: 'user', uuid: 'flow-cmd-echo', message: { role: 'user', content: '<command-name>/compact</command-name><command-message>compact</command-message><command-args></command-args><local-command-caveat>no need to respond</local-command-caveat>' } } })
67
- emit('jsonl_entry', { session_id: SID, entry: { type: 'user', uuid: 'flow-cmd-done', message: { role: 'user', content: [{ type: 'text', text: '<local-command-stdout>Compacted. Your new context length is 8,840 tokens</local-command-stdout>' }] } } })
68
- emit('typing', { active: false })
69
- }, 200)
70
- return json({ ok: true, session_id: SID, turn_number: 2 })
71
- }
72
- setTimeout(() => {
73
- emit('typing', { active: true })
74
- emit('jsonl_entry', { session_id: SID, entry: { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: '已收到,这是来自 TUI 的回复。' }] } } })
75
- emit('typing', { active: false })
76
- }, 200)
77
- return json({ ok: true, session_id: SID, turn_number: 1 })
78
- }
79
- if (url.endsWith(`/api/sessions/${SID}/status`)) {
80
- return json({ session_id: SID, alive: true, working: false })
81
- }
82
- // issues
83
- if (url.includes('/api/projects/') && url.includes('/issues') && method === 'POST') return json({ id: IID, project_id: PID, title: '命令行任务' }) // create issue
84
- if (url.includes('/api/projects/') && url.includes('/issues') && method === 'GET') return json([{ id: IID, project_id: PID, title: '命令行任务', description: '任务说明' }]) // list issues
85
- // projects
86
- if (url.includes('/api/projects') && method === 'GET') return json([{ id: PID, name: '已有项目甲', description: '项目说明' }]) // list projects
87
- if (url.endsWith('/api/projects') && method === 'POST') return json({ id: PID, name: '测试项目PTY' }) // create project (exact)
88
- // preference lookups
89
- if (url.includes('/sessions/model-options')) return json([{ key: 'codex', label: 'GPT-5.5', title: 'GPT-5.5', sub: 'Codex', backend: 'tmux-codex' }])
90
- if (url.includes('/sessions/default-model')) return json({ model: 'codex' })
91
- if (url.includes('/skills')) return json([])
92
- if (url.includes('/memories')) return json([])
93
- return json({ error: `unmocked ${method} ${url}` }, 404)
94
- }
95
-
96
- async function waitFor(lastFrame: () => string | undefined, needle: string, timeoutMs = 4000) {
97
- for (let i = 0; i < timeoutMs / 50; i++) {
98
- if ((lastFrame() ?? '').includes(needle)) return true
99
- await delay(50)
100
- }
101
- return false
102
- }
103
-
104
- async function main() {
105
- // Pre-seed login so App auto-logs in (login form itself is covered in ui.test).
106
- fs.writeFileSync(path.join(TMP_HOME, 'login.json'), JSON.stringify({
107
- server: 'http://mock.local', username: 'tester', token: 'mock-jwt-token',
108
- user: { id: 'tester', display_name: 'Test User', role: 'admin' },
109
- }))
110
- const realFetch = globalThis.fetch
111
- globalThis.fetch = ((u: any, init?: any) => mockFetch(String(u), init)) as unknown as typeof fetch
112
- process.env.MOBIUS_TUI_DEBUG = '1'
113
-
114
- console.log('\n[FLOW] full App drive (mocked backend)\n')
115
- const { stdin, lastFrame, unmount } = render(React.createElement(App))
116
-
117
- try {
118
- // ── prep: project picker (auto-logged in) ────────────────────────────────
119
- ok(await waitFor(lastFrame, '选择当前路径的绑定项目'), 'booted into project picker')
120
- // pick "➕ 创建新项目" (active index 0) → name wizard
121
- stdin.write('\r'); await delay(120)
122
- ok(await waitFor(lastFrame, '项目名称'), 'project create wizard opened')
123
- stdin.write('测试项目PTY'); await delay(120)
124
- stdin.write('\r'); await delay(300) // submit project name
125
- snap('1-prep-project-created', lastFrame() ?? '')
126
-
127
- // ── prep: issue picker (no issues → create) ──────────────────────────────
128
- ok(await waitFor(lastFrame, '创建新任务'), 'issue picker shown')
129
- stdin.write('\r'); await delay(120) // → create-name
130
- ok(await waitFor(lastFrame, '输入任务名称'), 'issue name wizard opened')
131
- stdin.write('\x1b'); await delay(120) // Esc → issue list
132
- ok(await waitFor(lastFrame, '选择任务(Issue)'), 'Esc returns from issue name wizard to issue list')
133
- stdin.write('\r'); await delay(120) // → create-name again
134
- ok(await waitFor(lastFrame, '输入任务名称'), 'issue name wizard can be reopened after Esc')
135
- stdin.write('命令行任务'); await delay(120)
136
- stdin.write('\r'); await delay(300) // create issue (worktree off) → model
137
-
138
- // ── prep: preferences ────────────────────────────────────────────────────
139
- ok(await waitFor(lastFrame, '选择模型'), 'model picker shown')
140
- stdin.write('\r'); await delay(250) // pick codex
141
- ok(await waitFor(lastFrame, '选择回复语言'), 'language picker shown')
142
- stdin.write('\r'); await delay(400) // zh; skills+memories empty → auto-skip
143
-
144
- // ── chat ─────────────────────────────────────────────────────────────────
145
- ok(await waitFor(lastFrame, '输入问题'), 'entered chat (preferences complete)')
146
- snap('2-chat-ready', lastFrame() ?? '')
147
-
148
- // send a message — expect streamed assistant reply
149
- stdin.write('你好,请回复一句话'); await delay(120)
150
- stdin.write('\r')
151
- ok(await waitFor(lastFrame, '已收到', 6000), 'assistant reply streamed into transcript')
152
- await delay(300)
153
- snap('3-chat-after-reply', lastFrame() ?? '')
154
-
155
- // ── /clear ───────────────────────────────────────────────────────────────
156
- stdin.write('/clear'); await delay(120)
157
- stdin.write('\r')
158
- ok(await waitFor(lastFrame, '输入问题'), '/clear reset to a fresh chat')
159
- snap('4-after-clear', lastFrame() ?? '')
160
-
161
- // ── /resume ──────────────────────────────────────────────────────────────
162
- // /resume — wait for the post-/clear remount to settle, then type slowly.
163
- await delay(500)
164
- stdin.write('/resume'); await delay(300)
165
- stdin.write('\r')
166
- await delay(500)
167
- snap('4b-resume-picker', lastFrame() ?? '')
168
- ok(await waitFor(lastFrame, '恢复历史会话'), '/resume picker opened')
169
- ok((lastFrame() ?? '').includes('历史会话一'), 'resume list shows the past session')
170
- stdin.write('\r'); await delay(500) // pick session → reconnect SSE
171
- ok(await waitFor(lastFrame, '输入问题'), 'resumed into chat')
172
- snap('5-after-resume', lastFrame() ?? '')
173
-
174
- // ── /config: full reconfigure (project → issue → model) ──────────────
175
- // Unlike /model, /config walks through project, issue, AND model steps.
176
- await delay(400)
177
- stdin.write('/config'); await delay(300)
178
- stdin.write('\r')
179
- ok(await waitFor(lastFrame, '重新配置'), '/config opens the full reconfig flow')
180
- ok(await waitFor(lastFrame, '选择项目'), '/config shows project picker first')
181
- ok(await waitFor(lastFrame, '已有项目甲 - 项目说明'), '/config keeps the project explanation on its main row')
182
- // Pick the first project (created above), then verify Esc walks back one
183
- // level at a time instead of closing the entire config flow.
184
- stdin.write('\r'); await delay(400)
185
- ok(await waitFor(lastFrame, '选择任务'), '/config shows issue picker after project')
186
- ok((lastFrame() ?? '').includes('命令行任务 - 任务说明'), '/config keeps the issue explanation on its main row')
187
- stdin.write('\x1b'); await delay(180)
188
- ok(await waitFor(lastFrame, '选择项目'), 'Esc from issue selection returns to project selection')
189
- stdin.write('\r'); await delay(400)
190
- ok(await waitFor(lastFrame, '选择任务'), 'project selection can be re-entered after Esc')
191
-
192
- // Pick the issue and verify the model step also returns to the issue step.
193
- stdin.write('\r'); await delay(400)
194
- ok(await waitFor(lastFrame, '选择模型'), '/config shows model picker after issue')
195
- ok(await waitFor(lastFrame, 'GPT-5.5'), '/config model list rendered')
196
- ok((lastFrame() ?? '').includes('GPT-5.5 (默认) - Codex'), '/config keeps the model explanation on its main row')
197
- stdin.write('\x1b'); await delay(180)
198
- ok(await waitFor(lastFrame, '选择任务'), 'Esc from model selection returns to issue selection')
199
- stdin.write('\r'); await delay(400)
200
- ok(await waitFor(lastFrame, '选择模型'), 'issue selection can be re-entered after Esc')
201
- stdin.write('\r'); await delay(700) // pick codex → create session
202
- ok(await waitFor(lastFrame, '输入问题'), '/config creates a fresh session and returns to chat')
203
- ok((lastFrame() ?? '').includes('?session=sess-1'), 'reconfigured chat is attached to the new session')
204
- snap('6-after-config', lastFrame() ?? '')
205
-
206
- // ── /model: swap model only, keep current task ─────────────────────────
207
- // No issue/project step — the current task is kept; pick a model and App
208
- // remounts Chat on the eagerly created session.
209
- await delay(400)
210
- stdin.write('/model'); await delay(300)
211
- stdin.write('\r')
212
- ok(await waitFor(lastFrame, '更换模型'), '/model opens the model picker directly')
213
- ok(await waitFor(lastFrame, '选择模型'), 'model picker shown without an issue-selection step')
214
- ok((lastFrame() ?? '').includes('当前任务: 命令行任务'), 'current task is kept (issue not changed)')
215
- ok(await waitFor(lastFrame, 'GPT-5.5'), 'model list rendered (Select mounted)')
216
- stdin.write('\r'); await delay(700) // pick codex → create session
217
- ok(await waitFor(lastFrame, '输入问题'), '/model creates a fresh session and returns to chat')
218
- ok((lastFrame() ?? '').includes('?session=sess-1'), '/model new session attached')
219
- snap('7-after-model', lastFrame() ?? '')
220
-
221
- // ── /compact: dispatch the literal command on the live session ────────
222
- await delay(400)
223
- stdin.write('/compact'); await delay(200)
224
- stdin.write('\r')
225
- ok(await waitFor(lastFrame, '上下文已压缩', 6000), '/compact renders the compact completion system line')
226
- ok(lastMessageBody?.content === '/compact', '/compact posts the literal command to the session (web parity)')
227
- snap('7b-after-compact', lastFrame() ?? '')
228
-
229
- // ── /logout ─────────────────────────────────────────────────────────────
230
- await delay(400)
231
- stdin.write('/logout'); await delay(150)
232
- stdin.write('\r')
233
- ok(await waitFor(lastFrame, 'Mobius 登录'), '/logout returns to the login form')
234
- ok(!fs.existsSync(path.join(TMP_HOME, 'login.json')), '/logout clears the persisted login token')
235
- ok((lastFrame() ?? '').includes('http://mock.local') && (lastFrame() ?? '').includes('tester'), '/logout keeps server and username available for the next login')
236
- snap('6-after-config', lastFrame() ?? '')
237
- } finally {
238
- unmount()
239
- globalThis.fetch = realFetch
240
- }
241
-
242
- console.log('\n──────── captured frames ────────')
243
- for (const s of snapshots) {
244
- console.log(`\n── ${s.step} ──`)
245
- console.log(s.frame.replace(/\n{3,}/g, '\n\n').trim())
246
- }
247
-
248
- try { fs.rmSync(TMP_HOME, { recursive: true, force: true }) } catch { /* ignore */ }
249
- console.log(`\n==== FLOW RESULT: ${pass} passed, ${fail} failed ====\n`)
250
- process.exit(fail === 0 ? 0 : 1)
251
- }
252
-
253
- main().catch((e) => { console.error('FATAL', e); process.exit(2) })