feat: 内容窗收敛4项 + 终端loadPkg修复(E2E验证) + 文件路由 + 标签跨窗拖动吸附 + 自动化测试
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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 点击开标签/拖动吸附)刷新即生效。
|
||||
|
||||
@@ -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); });
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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); });
|
||||
Reference in New Issue
Block a user