feat: 内容窗收敛4项 + 终端loadPkg修复(E2E验证) + 文件路由 + 标签跨窗拖动吸附 + 自动化测试

This commit is contained in:
Aisland
2026-08-17 12:49:27 +08:00
parent eabd367e48
commit dcb75d78cc
7 changed files with 329 additions and 17 deletions
+92 -14
View File
@@ -91,6 +91,7 @@ type SplitState = {
openTab(row: PaneRow, i: number, content: SplitContent): void
closeTab(row: PaneRow, i: number, tabId: string): void
setActiveTab(row: PaneRow, i: number, tabId: string): void
moveTab(fromRow: PaneRow, fromI: number, tabId: string, toRow: PaneRow, toI: number): void
swapPanes(aRow: PaneRow, aI: number, bRow: PaneRow, bI: number): void
setChatSide(side: 'left' | 'right'): void
persist(): void
@@ -178,6 +179,16 @@ function parentPathOf(p: string): string {
/** 拖拽换位暂存 */
let dragPane: { row: PaneRow; index: number } | null = null
/** 标签拖拽暂存(跨窗移动) */
let dragTab: { row: PaneRow; index: number; tabId: string } | null = null
/** 标签拖放目标(吸附高亮;模块级,PaneBody 与窗容器共用) */
let dropTarget: { row: PaneRow; index: number } | null = null
let dropTargetListeners: Set<() => void> = new Set()
function setDropTarget(t: { row: PaneRow; index: number } | null) {
if (dropTarget === t) return
dropTarget = t
for (const fn of dropTargetListeners) fn()
}
/** 找到会话根容器:data-phase 元素中排除输入框、取含子元素者;优先 phase=active;无活动会话返回 null */
function findConversationRoot(): HTMLElement | null {
@@ -266,7 +277,7 @@ export const splitStore: SplitState = {
window.dispatchEvent(new CustomEvent('dsh:split-claim', { detail: { id: spec.id } }))
} catch {}
const root = findConversationRoot()
if (!root || root.dataset.phase !== 'active') return false
if (!root) return false
const header = root.children[0] as HTMLElement | undefined
const viewArea = root.children[1] as HTMLElement | undefined
if (!header || !viewArea) return false
@@ -544,6 +555,36 @@ export const splitStore: SplitState = {
this.notify()
},
moveTab(fromRow, fromI, tabId, toRow, toI) {
const spec = this.spec
if (!spec) return
if (fromRow === toRow && fromI === toI) return
const top = [...(spec.top ?? [])]
const main = [...spec.main]
const left = spec.left ? { ...spec.left } : null
const arrOf = (row: PaneRow): SplitPane[] => (row === 'left' ? (left ? [left] : []) : row === 'top' ? top : main)
const fromArr = arrOf(fromRow)
const toArr = arrOf(toRow)
const fromPane = fromArr[fromI]
const toPane = toArr[toI]
if (!fromPane || !toPane) return
const tab = (fromPane.tabs ?? []).find((t) => t.id === tabId)
if (!tab) return
const fromTabs = (fromPane.tabs ?? []).filter((t) => t.id !== tabId)
const toTabs = [...(toPane.tabs ?? []), tab]
const setPane = (row: PaneRow, i: number, pane: SplitPane) => {
if (row === 'left') spec.left = pane
else if (row === 'top') top[i] = pane
else main[i] = pane
}
setPane(fromRow, fromI, { ...fromPane, tabs: fromTabs, active: 0 })
setPane(toRow, toI, { ...toPane, tabs: toTabs, active: toTabs.length - 1 })
this.spec = { ...spec, left: left ?? null, top: top.length > 0 ? top : null, main }
this.onSpecMutated?.(this.spec)
this.persist()
this.notify()
},
setActiveTab(row, i, tabId) {
const spec = this.spec
if (!spec) return
@@ -743,8 +784,8 @@ function FileIcon() {
)
}
/** 资源管理器窗:树形展开(懒加载子目录;刷新/上一级均可用) */
function ExplorerPane() {
/** 资源管理器窗:树形展开(懒加载子目录;刷新/上一级均可用;.html 点击开浏览器标签) */
function ExplorerPane(props: { row: PaneRow; index: number }) {
const cacheRef = useRef<Record<string, any[]>>({})
const expandedRef = useRef<Set<string>>(new Set())
const [rootPath, setRootPath] = useState('')
@@ -815,7 +856,14 @@ function ExplorerPane() {
type="button"
className="dsh-wt_treeRow"
style={{ paddingLeft: 8 + depth * 14 }}
onClick={() => (e.isDir ? toggle(e.path) : setError(T('pane.openLater')))}
onClick={() => {
if (e.isDir) { toggle(e.path); return }
if (/\.html?$/i.test(e.name)) {
splitStore.openTab(props.row, props.index, { kind: 'iframe', url: '/api/worktable/file?path=' + encodeURIComponent(e.path) })
} else {
setError(T('pane.openLater'))
}
}}
>
<span className={'dsh-wt_treeArrow' + (e.isDir && isOpen ? ' dsh-wt_treeArrowOpen' : '')} aria-hidden>{e.isDir ? '▸' : ''}</span>
{e.isDir ? <FolderIcon /> : <FileIcon />}
@@ -969,13 +1017,13 @@ function TerminalPane() {
}
/** 单个标签页的内容渲染 */
function PaneTabBody(props: { tab: PaneTab }) {
function PaneTabBody(props: { tab: PaneTab; row: PaneRow; index: number }) {
const content = props.tab.content
if (content.kind === 'iframe') {
return <iframe className="dsh-wt_paneFrame" src={content.url} title={props.tab.title} />
}
if (content.type === 'browser') return <BrowserPane />
if (content.type === 'explorer') return <ExplorerPane />
if (content.type === 'explorer') return <ExplorerPane row={props.row} index={props.index} />
if (content.type === 'scm') return <GitPane />
if (content.type === 'tasks') return <JobsPane />
if (content.type === 'terminal') return <TerminalPane />
@@ -1003,6 +1051,9 @@ function PaneBody(props: { pane: SplitPane; row: PaneRow; index: number }) {
key={t.id}
className={'dsh-wt_tab' + (i === active ? ' dsh-wt_tabOn' : '')}
title={t.title}
draggable
onDragStart={(e: any) => { dragTab = { row, index, tabId: t.id }; try { e.dataTransfer.effectAllowed = 'move' } catch {} }}
onDragEnd={() => { dragTab = null; setDropTarget(null) }}
onClick={() => splitStore.setActiveTab(row, index, t.id)}
>
<span className="dsh-wt_tabTitle">{t.title}</span>
@@ -1015,7 +1066,7 @@ function PaneBody(props: { pane: SplitPane; row: PaneRow; index: number }) {
</span>
))}
</div>
<PaneTabBody tab={tabs[active]} />
<PaneTabBody tab={tabs[active]} row={row} index={index} />
</>
)
}
@@ -1052,12 +1103,6 @@ function PanePicker(props: { row: PaneRow; index: number }) {
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'explorer' })}>
<span aria-hidden>📁</span>{T('pane.explorer')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'scm' })}>
<span aria-hidden>🔀</span>{T('pane.scm')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'tasks' })}>
<span aria-hidden>✅</span>{T('pane.tasks')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'terminal' })}>
<span aria-hidden>▸_</span>{T('pane.terminal')}
</button>
@@ -1101,6 +1146,14 @@ function SplitWorkspace() {
return () => window.removeEventListener('keydown', onKey)
}, [snap.active])
// 标签拖放高亮 → 重渲染
const [, setDropTick] = useState(0)
useEffect(() => {
const fn = () => setDropTick((t) => t + 1)
dropTargetListeners.add(fn)
return () => { dropTargetListeners.delete(fn) }
}, [])
if (!snap.active || !snap.spec || !snap.geom) return null
const g = snap.geom
const spec = snap.spec
@@ -1135,7 +1188,29 @@ function SplitWorkspace() {
const topY = barTop + BAR_H
const renderPane = (it: { pane: SplitPane; left: number; width: number }, row: PaneRow, index: number, x: number, y: number, h: number) => (
<div key={it.pane.id} className="dsh-wt_pane" style={{ position: 'fixed', left: x + it.left, top: y, width: it.width, height: h, zIndex: 68 }}>
<div
key={it.pane.id}
className="dsh-wt_pane"
data-drop-hover={dropTarget && dropTarget.row === row && dropTarget.index === index ? 'true' : undefined}
style={{ position: 'fixed', left: x + it.left, top: y, width: it.width, height: h, zIndex: 68 }}
onDragOver={(e: any) => {
if (!dragTab) return
e.preventDefault()
setDropTarget({ row, index })
}}
onDragLeave={(e: any) => {
if (e.currentTarget && !e.currentTarget.contains(e.relatedTarget)) setDropTarget(null)
}}
onDrop={(e: any) => {
e.preventDefault()
const s = dragTab
dragTab = null
setDropTarget(null)
if (s && (s.row !== row || s.index !== index)) {
splitStore.moveTab(s.row, s.index, s.tabId, row, index)
}
}}
>
<div
className="dsh-wt_paneBar"
title={T('split.dragSwap')}
@@ -1228,4 +1303,7 @@ function SplitWorkspace() {
)
}
/** 调试出口(自动化验证用;必须在 store 定义之后) */
try { (window as any).__dshWorktable = { splitStore } } catch {}
export { SplitWorkspace }
+4 -2
View File
@@ -75,7 +75,8 @@ export const css = [
'.dsh-wt_splitTitle{flex:1;min-width:0;font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,#e6e8eb);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.dsh-wt_splitClose{flex:none;display:flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:none;border-radius:4px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font-size:12px;cursor:pointer}',
'.dsh-wt_splitClose:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06))}',
'.dsh-wt_pane{display:flex;flex-direction:column;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;background:var(--dsw-alias-bg-base,#0b0e14);overflow:hidden}',
'.dsh-wt_pane{display:flex;flex-direction:column;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;background:var(--dsw-alias-bg-base,#0b0e14);overflow:hidden;transition:border-color .15s ease,box-shadow .15s ease,transform .15s ease}',
'.dsh-wt_pane[data-drop-hover=true]{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);box-shadow:0 0 0 2px rgba(79,142,247,.4),var(--dsw-shadow-lv2,0 8px 24px rgba(0,0,0,.4));transform:scale(1.012)}',
'.dsh-wt_paneBar{flex:none;display:flex;align-items:center;height:22px;padding:0 8px;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02))}',
'.dsh-wt_paneTitle{flex:1;min-width:0;font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary,#9aa4b2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.dsh-wt_paneFrame{flex:1;width:100%;border:0;background:#010409;min-height:0}',
@@ -154,7 +155,8 @@ export const css = [
'.dsh-wt_termHost .xterm{height:100%}',
// 窗内标签页
'.dsh-wt_tabBar{flex:none;display:flex;align-items:center;gap:3px;padding:3px 5px 0;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02));overflow-x:auto}',
'.dsh-wt_tab{flex:none;display:flex;align-items:center;gap:5px;max-width:150px;padding:3px 8px;border:1px solid transparent;border-bottom:none;border-radius:6px 6px 0 0;color:var(--dsw-alias-label-secondary,#9aa4b2);font-size:10.5px;line-height:16px;cursor:pointer;user-select:none;-webkit-user-select:none}',
'.dsh-wt_tab{flex:none;display:flex;align-items:center;gap:5px;max-width:150px;padding:3px 8px;border:1px solid transparent;border-bottom:none;border-radius:6px 6px 0 0;color:var(--dsw-alias-label-secondary,#9aa4b2);font-size:10.5px;line-height:16px;cursor:grab;user-select:none;-webkit-user-select:none}',
'.dsh-wt_tab:active{cursor:grabbing}',
'.dsh-wt_tab:hover{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.04))}',
'.dsh-wt_tabOn{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-bg-base,#0b0e14);border-color:var(--dsw-alias-border-l1,#262b36)}',
'.dsh-wt_tabTitle{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
+44 -1
View File
@@ -1,8 +1,10 @@
import { Context } from '@deepseek-ai/cordis'
import { execFile } from 'node:child_process'
import { readdir, realpathSync } from 'node:fs'
import { readdirSync, readFile, realpathSync } from 'node:fs'
import { readdir } from 'node:fs/promises'
import { dirname, resolve as pathResolve, sep } from 'node:path'
import { createRequire } from 'node:module'
import { homedir } from 'node:os'
import { fileURLToPath, pathToFileURL } from 'node:url'
/**
@@ -40,6 +42,18 @@ function loadPkg(pkg: string): any | null {
dir = pathResolve(dir, '..')
}
}
// 兜底:DSH 标准目录 ~/.dsh/profiles/*/node_modules(宿主按 realpath 加载时前两条链都找不到)
try {
const profilesDir = pathResolve(homedir(), '.dsh', 'profiles')
for (const profile of readdirSync(profilesDir, { withFileTypes: true })) {
if (!profile.isDirectory() && !profile.isSymbolicLink()) continue
const nm = pathResolve(profilesDir, profile.name, 'node_modules')
try {
const req = createRequire(pathToFileURL(pathResolve(nm, '__wt_probe__.js')).href)
return req(pkg)
} catch {}
}
} catch {}
return null
}
@@ -115,6 +129,7 @@ async function setupTerminal(webServer: any, ctx: any) {
try { ptyMod = await import('node-pty') } catch {}
if (!wsMod) wsMod = loadPkg('ws')
if (!ptyMod) ptyMod = loadPkg('node-pty')
ctx.logger?.info?.('[dsh-worktable] term deps: ws=' + (wsMod ? 'ok' : 'MISSING') + ' node-pty=' + (ptyMod ? 'ok' : 'MISSING'))
if (!wsMod || !ptyMod) {
ctx.logger?.warn('[dsh-worktable] 终端路由未注册:ws/node-pty 均不可用')
return
@@ -177,6 +192,34 @@ export function apply(ctx: Context) {
},
})
// 本地文件读取(资源管理器点击 .html 后浏览器标签内打开)
webServer.register({
kind: 'exact',
path: '/api/worktable/file',
handler: async (req: any, res: any) => {
try {
const u = new URL(req.url ?? '/', 'http://dsh.internal')
const p = u.searchParams.get('path') || ''
if (!p) { json(res, 400, { error: 'missing path' }); return }
const abs = resolve(p)
const stat = await import('node:fs/promises').then((m) => m.stat(abs))
if (stat.size > 20 * 1024 * 1024) { json(res, 413, { error: 'file too large' }); return }
const data = await readFile(abs)
const ext = (abs.split('.').pop() || '').toLowerCase()
const types: Record<string, string> = {
html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8',
json: 'application/json; charset=utf-8', md: 'text/plain; charset=utf-8', txt: 'text/plain; charset=utf-8',
svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
}
res.writeHead(200, { 'content-type': types[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' })
res.end(data)
} catch (err) {
json(res, 404, { error: String(err) })
}
},
})
webServer.register({
kind: 'exact',
path: '/api/worktable/fs',
@@ -408,3 +408,23 @@
node-pty/ws。**需重启 dsh web 后终端路由才注册。**
- 验证:构建 + node --check 通过;headless Chrome 回归零报错;依赖解析测试通过。
- 差距(M3 待续):文件打开/编辑器、SCM diff/暂存/提交、任务输出回放。
## 补记(内容窗收敛为 4 项 + 终端修复验证 + 标签跨窗拖动)
- 用户定案:内容窗收敛为 4 项(浏览器/资源管理器/终端/自定义),源代码管理与任务管理
暂砍(非重点);重点修终端并验证浏览器。
- 实现:
① 选择器砍为 4 项(SCM/Tasks 移除);
② 终端修复:loadPkg 沿「junction 路径 + realpath + ~/.dsh/profiles/*/node_modules」
三链解析 ws/node-pty(此前 import 直接失败);修 readdir 误用(回调版→readdirSync);
新增 04_test/term-e2e.cjs 端到端测试(模拟宿主经 junction 加载 bundle,真实 ws 客户端 +
真实 node-pty shell)→ **RESULT: PASS(echo 回显正常)**;
③ 资源管理器点击 .html/.htm → 自动开浏览器标签(/api/worktable/file 服务端文件路由,
内容类型按扩展名,20MB 上限);
④ 标签跨窗拖动:标签 draggable + 窗容器接收(dragOver/drop)+ 吸附动画
(data-drop-hover 边框辉光 + scale 过渡)+ moveTab(源移除/目标追加/激活末位/持久化);
⑤ 调试出口 window.__dshWorktable={splitStore} + 04_test/functional-diag.cjs
(headless Chrome 分步自动化:打开布局→2 选择器×4 选项→开浏览器/资源管理器标签→
跨窗移动标签→关闭)→ **全绿、零报错**。
- 注意:服务端改动(file 路由 + 终端 loadPkg)需**重启 dsh web 生效**;
客户端改动(4 选项/HTML 点击开标签/拖动吸附)刷新即生效。
+88
View File
@@ -0,0 +1,88 @@
const http = require('http');
const { spawn } = require('child_process');
const WebSocket = require('ws');
const PORT = 9335;
const proc = spawn('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', [
'--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
'--remote-debugging-port=' + PORT,
'--user-data-dir=C:\\Users\\SJL\\AppData\\Local\\Temp\\wt-chrome-func2',
'about:blank',
], { stdio: 'ignore' });
function getJSON(path) {
return new Promise((res, rej) => {
http.get({ host: '127.0.0.1', port: PORT, path }, (r) => {
let d = '';
r.on('data', (c) => d += c);
r.on('end', () => { try { res(JSON.parse(d)) } catch (e) { rej(e) } });
}).on('error', rej);
});
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
let list = null;
for (let i = 0; i < 40; i++) {
try { list = await getJSON('/json/list'); if (list && list.length) break; } catch {}
await sleep(500);
}
const target = list.find((t) => t.type === 'page') || list[0];
const ws = new WebSocket(target.webSocketDebuggerUrl);
let id = 0;
const pending = new Map();
const events = [];
const send = (method, params) => new Promise((res) => {
const mid = ++id;
pending.set(mid, res);
ws.send(JSON.stringify({ id: mid, method, params }));
});
ws.on('message', (raw) => {
const msg = JSON.parse(String(raw));
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
else if (msg.method) events.push(msg);
});
await new Promise((r) => ws.on('open', r));
await send('Runtime.enable');
await send('Log.enable');
await send('Page.enable');
await send('Page.navigate', { url: 'http://127.0.0.1:3080/' });
await sleep(11000);
const evaluate = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
if (r.result && r.result.exceptionDetails) return { error: (r.result.exceptionDetails.exception || {}).description || r.result.exceptionDetails.text };
return r.result && r.result.result ? r.result.result.value : undefined;
};
const step1 = await evaluate("(function(){ var out={}; out.debugExport=!!window.__dshWorktable; var st=window.__dshWorktable&&window.__dshWorktable.splitStore; if(!st) return JSON.stringify(out); out.openResult=st.open({id:'t-diag',title:'diag',top:null,main:[{id:'p1',title:'1',min:200,content:null},{id:'p2',title:'2',min:200,content:null}],chatWidth:{default:320,min:240,max:600}}); out.afterOpen={active:st.active,spec:st.spec&&st.spec.id}; return JSON.stringify(out) })()");
console.log('STEP1:', JSON.stringify(step1));
await sleep(900);
const step2 = await evaluate("(function(){ var out={}; out.pickers=document.querySelectorAll('.dsh-wt_panePicker').length; out.pickButtons=document.querySelectorAll('.dsh-wt_panePick').length; var st=window.__dshWorktable.splitStore; st.openTab('main',0,{kind:'builtin',type:'browser'}); st.openTab('main',1,{kind:'builtin',type:'explorer'}); return JSON.stringify(out) })()");
console.log('STEP2:', JSON.stringify(step2));
await sleep(900);
const step3 = await evaluate("(function(){ var out={}; out.browserBar=document.querySelectorAll('.dsh-wt_browserBar').length; out.browserIframe=document.querySelectorAll('.dsh-wt_paneFrame').length; out.explorerBars=document.querySelectorAll('.dsh-wt_subBar').length; out.tabs=document.querySelectorAll('.dsh-wt_tab').length; return JSON.stringify(out) })()");
console.log('STEP3:', JSON.stringify(step3));
const step4 = await evaluate("(function(){ var st=window.__dshWorktable.splitStore; var tabId=st.spec.main[0].tabs[0].id; st.moveTab('main',0,tabId,'main',1); var out={pane0Tabs:(st.spec.main[0].tabs||[]).length,pane1Tabs:(st.spec.main[1].tabs||[]).length}; return JSON.stringify(out) })()");
console.log('STEP4:', JSON.stringify(step4));
await sleep(700);
const step5 = await evaluate("(function(){ var out={}; out.pane0PickerBack=document.querySelectorAll('.dsh-wt_panePicker').length; var st=window.__dshWorktable.splitStore; st.close(); out.closed=st.active===false; return JSON.stringify(out) })()");
console.log('STEP5:', JSON.stringify(step5));
const errors = events.filter((e) =>
e.method === 'Runtime.exceptionThrown' ||
(e.method === 'Log.entryAdded' && e.params.entry.level === 'error') ||
(e.method === 'Runtime.consoleAPICalled' && e.params.type === 'error'),
).slice(0, 10).map((e) => e.method === 'Runtime.exceptionThrown'
? 'EXCEPTION: ' + ((e.params.exceptionDetails.exception || {}).description || e.params.exceptionDetails.text)
: 'LOG: ' + (e.params.entry ? e.params.entry.text : '').slice(0, 200));
console.log('ERRORS_COUNT:', errors.length);
errors.forEach((x) => console.log(x));
ws.close();
proc.kill();
process.exit(0);
})().catch((e) => { console.log('SCRIPT_FAIL:', e); proc.kill(); process.exit(1); });
+28
View File
@@ -0,0 +1,28 @@
const { createRequire } = require('module');
const { pathToFileURL } = require('url');
const fs = require('fs');
const os = require('os');
const path = require('path');
function tryResolve(fromDir, pkg) {
try {
const req = createRequire(pathToFileURL(fromDir.replace(/\\/g,'/') + '/__probe__.js').href);
return req.resolve(pkg);
} catch (e) { return null; }
}
console.log('--- 沿 junction 链 ---');
let d = 'C:/Users/SJL/.dsh/profiles/web/node_modules/dsh-worktable/lib';
while (true) {
const r = tryResolve(d, 'ws');
if (r) { console.log('FOUND at', d, '->', r); break; }
const parent = d.replace(/[\\/][^\\/]+$/, '');
if (!parent || parent === d) { console.log('链走完未找到'); break; }
d = parent;
}
console.log('--- homedir profiles 兜底 ---');
const pd = path.resolve(os.homedir(), '.dsh', 'profiles');
console.log('profilesDir:', pd, 'exists:', fs.existsSync(pd));
for (const p of fs.readdirSync(pd, { withFileTypes: true })) {
const nm = path.resolve(pd, p.name, 'node_modules');
console.log(p.name, 'isDir:', p.isDirectory(), 'nmExists:', fs.existsSync(nm), 'ws:', tryResolve(nm, 'ws'), 'pty:', tryResolve(nm, 'node-pty'));
}
+53
View File
@@ -0,0 +1,53 @@
/**
* 终端端到端测试:模拟宿主加载方式(经 profile junction 路径 import 服务端 bundle),
* 用真实 http server + ws 客户端验证 /api/worktable/term 能否拉起 shell。
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const WebSocket = require('ws');
(async () => {
const bundleUrl = 'file:///C:/Users/SJL/.dsh/profiles/web/node_modules/dsh-worktable/lib/index.js';
const mod = await import(bundleUrl);
const upgrades = [];
const routes = [];
const ctx = {
logger: {
warn: (...a) => console.log('[logger-warn]', ...a),
info: (...a) => console.log('[logger-info]', ...a),
},
sessions: { get: () => undefined },
webServer: {
register(r) { routes.push(r); return () => {} },
registerUpgrade(r) { upgrades.push(r); return () => {} },
},
};
mod.apply(ctx);
await new Promise((r) => setTimeout(r, 2500)); // 等异步 setupTerminal 完成(真实宿主进程长驻)
console.log('routes:', routes.map((r) => r.path).join(', '));
console.log('upgrade routes:', upgrades.map((r) => r.path).join(', ') || '(none)');
if (upgrades.length === 0) { console.log('RESULT: NO_TERMINAL_ROUTE'); process.exit(1); }
const server = http.createServer((req, res) => { res.writeHead(404); res.end(); });
server.on('upgrade', (req, socket, head) => {
const u = new URL(req.url ?? '/', 'http://x');
const route = upgrades.find((r) => u.pathname === r.path);
if (!route) { socket.destroy(); return; }
route.handler(req, socket, head);
});
await new Promise((r) => server.listen(19087, '127.0.0.1', r));
const ws = new WebSocket('ws://127.0.0.1:19087/api/worktable/term?cwd=' + encodeURIComponent(process.cwd()) + '&cols=80&rows=24');
const received = [];
const done = (code, msg) => { console.log('RESULT: ' + code + ' ' + msg); try { ws.close(); } catch {} server.close(); process.exit(code === 'PASS' ? 0 : 1); };
const timer = setTimeout(() => done('FAIL', '10s 内无输出'), 10000);
ws.on('open', () => { ws.send('echo __WT_TERM_OK__\r'); });
ws.on('message', (d) => {
const t = String(d);
received.push(t);
if (t.includes('__WT_TERM_OK__')) { clearTimeout(timer); done('PASS', 'shell 回显正常 (' + received.length + ' 帧)'); }
});
ws.on('error', (e) => { clearTimeout(timer); done('FAIL', String(e)); });
})().catch((e) => { console.log('RESULT: HARNESS_FAIL', e); process.exit(1); });