fix(v0.3.3): 分栏引擎双版本锚点(0.1.1-rc.2/0.1.2-rc.1) + 数据目录解析循环修复 + 发布流水线加防(锚点8场景/数据目录3场景) + 公告/文档同步
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* 分栏引擎 DOM 锚点回归测试(P2:DSH 0.1.1-rc.2 与 0.1.2-rc.1 双版本会话根结构)。
|
||||
* 用法:node anchor-dom.test.mjs(纯 node,无外部依赖;评估真实构建产物 lib/client.js)。
|
||||
* 覆盖:
|
||||
* A. 0.1.1 active(2 子元素 + 可见头部)→ 顶部 = 头部底边
|
||||
* B. 0.1.1/0.1.2 blank hero(2 子元素 + 隐藏头部)→ 头部忽略、顶部 = 根顶
|
||||
* C. 0.1.2 无会话 hero(1 子元素)→ 无头部、viewArea = 唯一子元素
|
||||
* D. hero→active 过渡(同根插入可见头部)→ 重锚定且关闭还原干净(不泄漏已应用值)
|
||||
* E. 关闭还原:margin 清空、--dsh-chat-user-width 移除
|
||||
* F. 输入包装器(uV2eYG_input / textarea)不被误选为会话根
|
||||
* G. settling 过渡态:不关闭、等待
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createContext, runInNewContext } from 'node:vm'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
|
||||
// bundle 路径参数化:发布脚本传入「已安装包内」的 lib/client.js(验收最终产物);
|
||||
// 缺省回退到工作目录构建产物(本地开发跑)。
|
||||
const BUNDLE = resolve(process.argv[2] ?? join(dirname(fileURLToPath(import.meta.url)), '..', '01_content', 'lib', 'client.js'))
|
||||
const source = readFileSync(BUNDLE, 'utf8')
|
||||
|
||||
// —— 假 DOM ——
|
||||
// 模拟浏览器 CSSStyleDeclaration:未设置的属性读作 ''(插件保存/还原依赖该语义)
|
||||
class StyleProxy {
|
||||
constructor() {
|
||||
return new Proxy(this, {
|
||||
get(t, k) {
|
||||
if (k === Symbol.toPrimitive) return () => 'style'
|
||||
return k in t ? t[k] : ''
|
||||
},
|
||||
})
|
||||
}
|
||||
getPropertyValue(k) { return Object.prototype.hasOwnProperty.call(this, k) ? this[k] : '' }
|
||||
setProperty(k, v) { this[k] = v }
|
||||
removeProperty(k) { delete this[k] }
|
||||
}
|
||||
// 万能桩:任何未实现的 DOM API(getContext 等)返回自身同型桩
|
||||
const universalStub = new Proxy(function () { return universalStub }, {
|
||||
get: (_t, k) => (k === Symbol.toPrimitive ? () => 'stub' : universalStub),
|
||||
apply: () => universalStub,
|
||||
construct: () => universalStub,
|
||||
})
|
||||
class FakeEl {
|
||||
constructor(tag = 'DIV', cls = '', attrs = {}) {
|
||||
this.tagName = tag
|
||||
this.className = cls
|
||||
this.dataset = {}
|
||||
for (const [k, v] of Object.entries(attrs)) this.dataset[k] = v
|
||||
this.children = []
|
||||
this.parentElement = null
|
||||
this.style = new StyleProxy()
|
||||
this._rect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }
|
||||
this._connected = true
|
||||
return new Proxy(this, {
|
||||
get(t, k) {
|
||||
if (k in t) return t[k]
|
||||
if (k === Symbol.toPrimitive) return () => 'stub'
|
||||
return universalStub
|
||||
},
|
||||
})
|
||||
}
|
||||
get isConnected() { return this._connected }
|
||||
getBoundingClientRect() { return { ...this._rect } }
|
||||
setRect(o) {
|
||||
Object.assign(this._rect, o)
|
||||
this._rect.width = this._rect.right - this._rect.left
|
||||
this._rect.height = this._rect.bottom - this._rect.top
|
||||
return this
|
||||
}
|
||||
appendChild(c) { c.parentElement = this; this.children.push(c); return c }
|
||||
insertBefore(c) { c.parentElement = this; this.children.unshift(c); return c }
|
||||
removeChild(c) { c.parentElement = null; this.children = this.children.filter((x) => x !== c); return c }
|
||||
_walk(out) { for (const c of this.children) { out.push(c); c._walk(out) } return out }
|
||||
querySelectorAll() { return this._walk([]) }
|
||||
querySelector() { return null }
|
||||
closest() { return null }
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
setAttribute(k, v) { this.dataset[k] = v }
|
||||
getAttribute(k) { return this.dataset[k] }
|
||||
removeAttribute(k) { delete this.dataset[k] }
|
||||
}
|
||||
|
||||
let pass = 0
|
||||
const fail = (name, detail) => { console.error('FAIL(' + name + '): ' + detail); process.exit(1) }
|
||||
const ok = (name) => { console.log('ok ' + name); pass++ }
|
||||
|
||||
function matches(el, sel) {
|
||||
if (sel === '[data-phase]') return el.dataset && el.dataset.phase != null
|
||||
if (sel.startsWith('.')) {
|
||||
const cls = String(el.className || '').split(/\s+/)
|
||||
return cls.includes(sel.slice(1))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function buildSandbox() {
|
||||
const allEls = []
|
||||
const moInstances = []
|
||||
class MOCtor {
|
||||
constructor(cb) { this.cb = cb; moInstances.push(this) }
|
||||
observe(target) { this._target = target }
|
||||
disconnect() {}
|
||||
}
|
||||
class ROCtor {
|
||||
constructor(cb) { this.cb = cb }
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
const localStorageStub = { getItem: () => null, setItem: () => {}, removeItem: () => {} }
|
||||
const documentStub = {
|
||||
body: new FakeEl('BODY'),
|
||||
documentElement: new FakeEl('HTML'),
|
||||
querySelector: (sel) => allEls.find((e) => matches(e, sel)) || null,
|
||||
querySelectorAll: (sel) => allEls.filter((e) => matches(e, sel)),
|
||||
createElement: (tag) => new FakeEl(String(tag).toUpperCase()),
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}
|
||||
const sandbox = {
|
||||
window: {},
|
||||
console: { log() {}, warn() {}, error() {} },
|
||||
setTimeout, clearTimeout,
|
||||
localStorage: localStorageStub,
|
||||
document: documentStub,
|
||||
atob: (s) => Buffer.from(String(s), 'base64').toString('binary'),
|
||||
btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'),
|
||||
Blob: function Blob() {},
|
||||
URL: { createObjectURL: () => 'blob:stub', revokeObjectURL: () => {} },
|
||||
MutationObserver: MOCtor,
|
||||
ResizeObserver: ROCtor,
|
||||
IntersectionObserver: function () { return { observe() {}, unobserve() {}, disconnect() {} } },
|
||||
CustomEvent: class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail } },
|
||||
requestAnimationFrame: (cb) => 0,
|
||||
cancelAnimationFrame: () => {},
|
||||
navigator: { userAgent: 'test', platform: 'Win32', language: 'zh-CN' },
|
||||
fetch: () => Promise.reject(new Error('fetch unavailable')),
|
||||
indexedDB: undefined,
|
||||
}
|
||||
sandbox.window.__ModuleLoader__ = { load: (reg) => { sandbox.__captured = reg } }
|
||||
sandbox.window.addEventListener = () => {}
|
||||
sandbox.window.removeEventListener = () => {}
|
||||
sandbox.window.dispatchEvent = () => {}
|
||||
sandbox.window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} })
|
||||
sandbox.window.localStorage = localStorageStub
|
||||
sandbox.window.document = documentStub
|
||||
sandbox.self = sandbox.window
|
||||
createContext(sandbox)
|
||||
runInNewContext(source, sandbox, { filename: 'client.js', timeout: 20000 })
|
||||
if (!sandbox.__captured || typeof sandbox.__captured.factory !== 'function') fail('load', 'factory not captured')
|
||||
const makeStub = () => {
|
||||
const stub = function () { return makeStub() }
|
||||
return new Proxy(stub, {
|
||||
get: (_t, k) => (k === Symbol.toPrimitive ? () => 'stub' : makeStub()),
|
||||
apply: () => makeStub(),
|
||||
construct: () => makeStub(),
|
||||
})
|
||||
}
|
||||
const requireStub = (id) => {
|
||||
if (id === 'react' || id === 'react/jsx-runtime') return makeStub()
|
||||
throw new Error('unexpected external require: ' + id)
|
||||
}
|
||||
const runSandbox = { __factory: sandbox.__captured.factory, __requireStub: requireStub }
|
||||
createContext(runSandbox)
|
||||
runInNewContext('__factory(__requireStub)', runSandbox, { timeout: 20000 })
|
||||
const splitStore = sandbox.window.__dshWorktable && sandbox.window.__dshWorktable.splitStore
|
||||
if (!splitStore) fail('load', 'window.__dshWorktable.splitStore not exported')
|
||||
return { sandbox, splitStore, allEls, moInstances }
|
||||
}
|
||||
|
||||
function makeSpec(id) {
|
||||
return { id, title: id, top: null, main: [{ id: 'p1', title: 'p1', min: 200, content: null }], chatWidth: { default: 320, min: 240, max: 600 } }
|
||||
}
|
||||
|
||||
// 每个场景独立 sandbox(避免模块级 taObserver 等状态串扰)
|
||||
function fresh() {
|
||||
const b = buildSandbox()
|
||||
return b
|
||||
}
|
||||
|
||||
// —— A. 0.1.1 active:2 子元素 + 可见头部 ——
|
||||
{
|
||||
const { sandbox, splitStore, allEls } = fresh()
|
||||
const root = new FakeEl('DIV', 'wSkVaW_root', { phase: 'active' }).setRect({ left: 200, top: 100, right: 1200, bottom: 800 })
|
||||
const header = new FakeEl('HEADER', 'wSkVaW_header').setRect({ left: 200, top: 100, right: 1200, bottom: 148 })
|
||||
const scroll = new FakeEl('DIV', 'wSkVaW_scrollBody').setRect({ left: 200, top: 148, right: 1200, bottom: 800 })
|
||||
root.appendChild(header); root.appendChild(scroll)
|
||||
allEls.push(root); sandbox.document.body.appendChild(root)
|
||||
const r = splitStore.open(makeSpec('t-a'))
|
||||
if (r !== true) fail('A.open', 'open() !== true')
|
||||
if (splitStore.header !== header) fail('A.header', 'header not anchored')
|
||||
if (splitStore.viewArea !== scroll) fail('A.viewArea', 'viewArea not children[1]')
|
||||
if (splitStore.geom.top !== 148) fail('A.geom', 'top=' + splitStore.geom.top + ' != 148')
|
||||
if (scroll.style.marginTop !== '26px') fail('A.marginTop', JSON.stringify(scroll.style.marginTop))
|
||||
if (root.style.getPropertyValue('--dsh-chat-user-width') === '') fail('A.widthVar', 'width var not pinned')
|
||||
splitStore.close()
|
||||
if (scroll.style.marginLeft !== '' || scroll.style.marginTop !== '') fail('A.close', 'margins not restored: ' + JSON.stringify(scroll.style))
|
||||
if (root.style.getPropertyValue('--dsh-chat-user-width') !== '') fail('A.closeVar', 'width var not removed')
|
||||
ok('A. 0.1.1 active(可见头部)锚定 + 关闭还原')
|
||||
}
|
||||
|
||||
// —— B. 0.1.1/0.1.2 blank hero:2 子元素 + 隐藏头部 ——
|
||||
{
|
||||
const { sandbox, splitStore, allEls } = fresh()
|
||||
const root = new FakeEl('DIV', 'wSkVaW_root', { phase: 'hero' }).setRect({ left: 200, top: 100, right: 1200, bottom: 800 })
|
||||
const header = new FakeEl('HEADER', 'wSkVaW_header wSkVaW_headerHidden').setRect({ left: 200, top: 100, right: 1200, bottom: 100 })
|
||||
const body = new FakeEl('DIV', 'wSkVaW_body').setRect({ left: 200, top: 100, right: 1200, bottom: 800 })
|
||||
root.appendChild(header); root.appendChild(body)
|
||||
allEls.push(root); sandbox.document.body.appendChild(root)
|
||||
const r = splitStore.open(makeSpec('t-b'))
|
||||
if (r !== true) fail('B.open', 'open() !== true')
|
||||
if (splitStore.header !== null) fail('B.header', 'hidden header should be null')
|
||||
if (splitStore.viewArea !== body) fail('B.viewArea', 'viewArea not body')
|
||||
if (splitStore.geom.top !== 100) fail('B.geom', 'top=' + splitStore.geom.top + ' != 100 (root top)')
|
||||
splitStore.close()
|
||||
ok('B. blank hero(隐藏头部)锚定')
|
||||
}
|
||||
|
||||
// —— C. 0.1.2 无会话 hero:1 子元素 ——
|
||||
{
|
||||
const { sandbox, splitStore, allEls } = fresh()
|
||||
const root = new FakeEl('DIV', 'wSkVaW_root', { phase: 'hero' }).setRect({ left: 280, top: 0, right: 1258, bottom: 761 })
|
||||
const body = new FakeEl('DIV', 'wSkVaW_body').setRect({ left: 280, top: 0, right: 1258, bottom: 761 })
|
||||
root.appendChild(body)
|
||||
allEls.push(root); sandbox.document.body.appendChild(root)
|
||||
const r = splitStore.open(makeSpec('t-c'))
|
||||
if (r !== true) fail('C.open', 'open() !== true')
|
||||
if (splitStore.header !== null) fail('C.header', 'header should be null')
|
||||
if (splitStore.viewArea !== body) fail('C.viewArea', 'viewArea not only-child')
|
||||
if (splitStore.geom.top !== 0) fail('C.geom', 'top=' + splitStore.geom.top + ' != 0')
|
||||
splitStore.close()
|
||||
ok('C. 0.1.2 无会话 hero(单子元素)锚定')
|
||||
}
|
||||
|
||||
// —— D. hero→active 同根过渡:头部出现 → 重锚定;关闭还原干净 ——
|
||||
{
|
||||
const { sandbox, splitStore, allEls, moInstances } = fresh()
|
||||
const root = new FakeEl('DIV', 'wSkVaW_root', { phase: 'hero' }).setRect({ left: 280, top: 0, right: 1258, bottom: 761 })
|
||||
const body = new FakeEl('DIV', 'wSkVaW_body').setRect({ left: 280, top: 0, right: 1258, bottom: 761 })
|
||||
root.appendChild(body)
|
||||
allEls.push(root); sandbox.document.body.appendChild(root)
|
||||
splitStore.open(makeSpec('t-d'))
|
||||
const mlBefore = body.style.marginLeft
|
||||
const bodyFallbackObs = moInstances.filter((m) => m._targeted === undefined)[moInstances.length - 1]
|
||||
// 模拟 hero→active:头部插入 + phase 翻转
|
||||
const header = new FakeEl('HEADER', 'wSkVaW_header').setRect({ left: 280, top: 0, right: 1258, bottom: 48 })
|
||||
root.insertBefore(header)
|
||||
root.dataset.phase = 'active'
|
||||
// 触发 body 级 fallback MutationObserver(按 observe 目标识别;取最后一个 = open() 里建的 fallback,跳过模块级 taObserver)
|
||||
const bodyMOs = moInstances.filter((m) => m._target === sandbox.document.body)
|
||||
const fallbackMO = bodyMOs[bodyMOs.length - 1]
|
||||
if (!fallbackMO || typeof fallbackMO.cb !== 'function') fail('D.mo', 'fallback MO not captured')
|
||||
fallbackMO.cb()
|
||||
if (splitStore.header !== header) fail('D.header', 're-anchor did not pick visible header')
|
||||
if (splitStore.viewArea !== body) fail('D.viewArea', 're-anchor lost body')
|
||||
if (splitStore.geom.top !== 48) fail('D.geom', 'top=' + splitStore.geom.top + ' != 48')
|
||||
if (body.style.marginLeft === '') fail('D.margin', 'margin not re-applied after transition')
|
||||
// 关闭:margin 与宽度变量必须回到「打开前」原值(空),不得泄漏已应用值
|
||||
splitStore.close()
|
||||
if (body.style.marginLeft !== '') fail('D.closeMargin', 'leaked margin: ' + JSON.stringify(body.style.marginLeft))
|
||||
if (body.style.marginTop !== '') fail('D.closeMarginTop', 'leaked marginTop: ' + JSON.stringify(body.style.marginTop))
|
||||
if (root.style.getPropertyValue('--dsh-chat-user-width') !== '') fail('D.closeVar', 'leaked width var: ' + root.style.getPropertyValue('--dsh-chat-user-width'))
|
||||
ok('D. hero→active 过渡重锚定 + 关闭零泄漏')
|
||||
}
|
||||
|
||||
// —— E. 无会话根:findConversationRoot 返回 null → open 返回 false(安全失败)——
|
||||
{
|
||||
const { sandbox, splitStore } = fresh()
|
||||
const r = splitStore.open(makeSpec('t-e'))
|
||||
if (r !== false) fail('E.open', 'should fail without conversation root')
|
||||
ok('E. 无会话根时安全失败(不崩溃)')
|
||||
}
|
||||
|
||||
// —— F. 输入包装器 / textarea 不被误选 ——
|
||||
{
|
||||
const { sandbox, splitStore, allEls } = fresh()
|
||||
const root = new FakeEl('DIV', 'wSkVaW_root', { phase: 'hero' }).setRect({ left: 280, top: 0, right: 1258, bottom: 761 })
|
||||
const body = new FakeEl('DIV', 'wSkVaW_body').setRect({ left: 280, top: 0, right: 1258, bottom: 761 })
|
||||
const inputWrap = new FakeEl('DIV', 'uV2eYG_input', { phase: 'plain' })
|
||||
const p = new FakeEl('P')
|
||||
inputWrap.appendChild(p)
|
||||
const ta = new FakeEl('TEXTAREA', '', { phase: 'inert' })
|
||||
root.appendChild(body); body.appendChild(inputWrap); body.appendChild(ta)
|
||||
allEls.push(root, inputWrap, ta); sandbox.document.body.appendChild(root)
|
||||
const r = splitStore.open(makeSpec('t-f'))
|
||||
if (r !== true) fail('F.open', 'open failed')
|
||||
if (splitStore.root !== root) fail('F.root', 'wrong root chosen')
|
||||
splitStore.close()
|
||||
ok('F. 输入包装器/textarea 不被误选为会话根')
|
||||
}
|
||||
|
||||
// —— H. 0.1.2 active:零高槽位包装 + 内部真实头部(实测结构) ——
|
||||
{
|
||||
const { sandbox, splitStore, allEls } = fresh()
|
||||
const root = new FakeEl('DIV', 'wSkVaW_root', { phase: 'active' }).setRect({ left: 280, top: 0, right: 1258, bottom: 761 })
|
||||
const wrap = new FakeEl('DIV', '').setRect({ left: 280, top: 0, right: 1258, bottom: 0 }) // display:contents 式零高包装
|
||||
const header = new FakeEl('HEADER', 'wSkVaW_header').setRect({ left: 280, top: 0, right: 1258, bottom: 75.67 })
|
||||
const body = new FakeEl('DIV', 'wSkVaW_body').setRect({ left: 280, top: 75.67, right: 1258, bottom: 761 })
|
||||
wrap.appendChild(header)
|
||||
root.appendChild(wrap); root.appendChild(body)
|
||||
allEls.push(root); sandbox.document.body.appendChild(root)
|
||||
const r = splitStore.open(makeSpec('t-h'))
|
||||
if (r !== true) fail('H.open', 'open() !== true')
|
||||
if (splitStore.header !== header) fail('H.header', 'should anchor to inner header, got ' + (splitStore.header && splitStore.header.tagName))
|
||||
if (splitStore.viewArea !== body) fail('H.viewArea', 'viewArea not body')
|
||||
if (splitStore.geom.top !== 75.67) fail('H.geom', 'top=' + splitStore.geom.top + ' != 75.67')
|
||||
splitStore.close()
|
||||
ok('H. 0.1.2 active(零高包装+内部头部)锚定到真实头部')
|
||||
}
|
||||
|
||||
// —— G. settling 过渡态:syncAnchor 等待(不关闭)——
|
||||
{
|
||||
const { sandbox, splitStore, allEls, moInstances } = fresh()
|
||||
const root = new FakeEl('DIV', 'wSkVaW_root', { phase: 'hero' }).setRect({ left: 280, top: 0, right: 1258, bottom: 761 })
|
||||
const body = new FakeEl('DIV', 'wSkVaW_body').setRect({ left: 280, top: 0, right: 1258, bottom: 761 })
|
||||
root.appendChild(body)
|
||||
allEls.push(root); sandbox.document.body.appendChild(root)
|
||||
splitStore.open(makeSpec('t-g'))
|
||||
root.dataset.phase = 'settling'
|
||||
const bodyMOs = moInstances.filter((m) => m._target === sandbox.document.body)
|
||||
const fallbackMO = bodyMOs[bodyMOs.length - 1]
|
||||
fallbackMO.cb()
|
||||
if (!splitStore.active) fail('G.close', 'settling should not close')
|
||||
ok('G. settling 过渡态保持等待')
|
||||
}
|
||||
|
||||
console.log('all anchor-dom tests passed: ' + pass + '/' + pass)
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 服务端数据目录解析回归测试(Codex 对抗审查:loadPkg ↔ resolveDshHomeSafe 循环调用 + 降级规则对齐官方)。
|
||||
* 用法:node server-home.test.mjs [lib/index.js 路径](缺省 = 工作目录构建产物)
|
||||
*
|
||||
* 场景(全部在子进程隔离目录中运行,父进程不改环境变量、不残留临时目录):
|
||||
* A. 官方分支:隔离目录里「明确准备官方工具包夹具」(与 @deepseek-ai/dsh-home-paths 同布局、同实现语义),
|
||||
* 断言 homeSource=official(证明代码确实走了官方分支并调用其 resolveDshHome)+ 探测次数有界 + 内容正确
|
||||
* B. BOM 容忍:状态码 + 解析后内容双断言
|
||||
* C. 兜底分支:官方包不可解析 → homeSource=fallback + 探测次数有界 + 内容正确(旧代码循环重入会产生上万次探测)
|
||||
* D/E/F. ~ / ~/ / ~\ 前缀展开与相对路径:断言错误信息包含「JSON 转义后的完整预期绝对路径」
|
||||
* 说明:DSH_HOME 空/纯空白 → 默认 ~/.dsh(真实目录)——测试刻意不触发,避免读取真实用户数据。
|
||||
* 失败路径不调用 process.exit(避免跳过 finally),统一收口到 process.exitCode。
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
// 构建产物路径参数化:发布脚本传入「已安装包内」的 lib/index.js;缺省回退工作目录构建产物
|
||||
const BUNDLE_SRC = resolve(process.argv[2] ?? join(HERE, '..', '01_content', 'lib', 'index.js'))
|
||||
|
||||
// 探测次数上界:正常解析(祖先链 + profiles 枚举)远低于此;旧代码循环重入会达到数千次
|
||||
const PROBE_BOUND = 100
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
function fail(name, detail) {
|
||||
failures.push(name + ': ' + detail)
|
||||
console.error('FAIL(' + name + '): ' + detail)
|
||||
}
|
||||
const ok = (name) => { console.log('ok ' + name); pass++ }
|
||||
|
||||
/** 临时 DSH 数据目录(含 storages/workspace.json 标记内容;无 BOM) */
|
||||
function makeHome(marker) {
|
||||
const home = mkdtempSync(join(tmpdir(), 'wt-home-test-'))
|
||||
mkdirSync(join(home, 'storages'), { recursive: true })
|
||||
writeFileSync(join(home, 'storages', 'workspace.json'), JSON.stringify({
|
||||
unit: { name: 'workspace', version: 2 },
|
||||
global: { initialized: true, workspaceIds: ['fake-' + marker], archivedSessionIds: [] },
|
||||
tables: { workspaces: { ['fake-' + marker]: { title: marker, sessionIds: ['s-' + marker] } } },
|
||||
}), 'utf8')
|
||||
return home
|
||||
}
|
||||
|
||||
/**
|
||||
* 子进程跑一个隔离场景:
|
||||
* - withOfficial=true 时,在隔离目录 node_modules 下「明确准备官方工具包夹具」
|
||||
* (与 @deepseek-ai/dsh-home-paths 同布局:lib/index.js 导出 resolveDshHome/expandHomePath/defaultDshHome,
|
||||
* 实现为其公开文档语义的忠实镜像)——验证我们调用官方分支与返回值处理的集成,
|
||||
* 上游包自身语义由官方维护;夹具不引入生产依赖。
|
||||
*/
|
||||
function runChild(envHome, name, withOfficial = false) {
|
||||
const isoDir = mkdtempSync(join(tmpdir(), 'wt-home-iso-'))
|
||||
let home = null
|
||||
try {
|
||||
mkdirSync(join(isoDir, 'lib'), { recursive: true })
|
||||
copyFileSync(BUNDLE_SRC, join(isoDir, 'lib', 'index.js'))
|
||||
if (withOfficial) {
|
||||
const pkgDir = join(isoDir, 'node_modules', '@deepseek-ai', 'dsh-home-paths')
|
||||
mkdirSync(join(pkgDir, 'lib'), { recursive: true })
|
||||
writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-home-paths', version: '0.1.2-rc.1', type: 'module', main: 'lib/index.js' }), 'utf8')
|
||||
writeFileSync(join(pkgDir, 'lib', 'index.js'), [
|
||||
"import { homedir } from 'node:os'",
|
||||
"import { join, resolve } from 'node:path'",
|
||||
"export const DSH_HOME_DIR_NAME = '.dsh'",
|
||||
"export function defaultDshHome() { return join(homedir(), DSH_HOME_DIR_NAME) }",
|
||||
"export function expandHomePath(path) {",
|
||||
" if (path === '~') return homedir()",
|
||||
" if (path.startsWith('~/') || path.startsWith('~' + String.fromCharCode(92))) return join(homedir(), path.slice(2))",
|
||||
" return path",
|
||||
"}",
|
||||
"export function resolveDshHome(configured, env = process.env) {",
|
||||
" const fromEnv = env['DSH_HOME']",
|
||||
" return resolve(expandHomePath(configured ?? (fromEnv !== undefined && fromEnv.trim().length > 0 ? fromEnv : defaultDshHome())))",
|
||||
"}",
|
||||
"export function dshHomePath(...segments) { return join(resolveDshHome(), ...segments) }",
|
||||
"export function dshHomeDisplay(resolvedHome) {",
|
||||
" return resolvedHome === resolve(defaultDshHome()) ? '~/.dsh' : '$DSH_HOME'",
|
||||
"}",
|
||||
"export const DSH_HOME_ENV = 'DSH_HOME'",
|
||||
"export const DEFAULT_DSH_HOME_DISPLAY = '~/.dsh'",
|
||||
].join('\n'), 'utf8')
|
||||
}
|
||||
const homeVal = envHome === undefined ? (home = makeHome('MARKER_' + name)) : envHome
|
||||
const script = [
|
||||
"import { pathToFileURL } from 'node:url'",
|
||||
"import { resolve as pResolve, join as pJoin } from 'node:path'",
|
||||
"import { homedir } from 'node:os'",
|
||||
"const mod = await import(pathToFileURL(process.argv[1]).href)",
|
||||
"const routes = []",
|
||||
"const fakeCtx = { webServer: { register: (r) => { routes.push(r) } }, effect: () => {}, logger: { warn() {}, info() {} } }",
|
||||
"mod.apply(fakeCtx)",
|
||||
"const route = routes.find((r) => r.path === '/api/worktable/workspaces')",
|
||||
"let status = 0; let body = ''",
|
||||
"const res = { writeHead(s) { status = s }, end(b) { body = String(b) } }",
|
||||
"await route.handler({}, res)",
|
||||
"const stats = mod.__wtLoadProbeStats ? mod.__wtLoadProbeStats() : null",
|
||||
"const raw = process.env.DSH_HOME || ''",
|
||||
"const BS = String.fromCharCode(92)",
|
||||
"const expanded = raw === '~' ? homedir() : (raw.startsWith('~/') || raw.startsWith('~' + BS)) ? pJoin(homedir(), raw.slice(2)) : raw",
|
||||
"const expectedFull = pResolve(expanded, 'storages', 'workspace.json')",
|
||||
"const expectedEncoded = JSON.stringify(expectedFull).slice(1, -1)",
|
||||
"console.log(JSON.stringify({ status, body: body.slice(0, 500), stats, expectedFull, fullMatch: body.includes(expectedEncoded) }))",
|
||||
].join('\n')
|
||||
const r = spawnSync(process.execPath, ['--input-type=module', '-e', script, join(isoDir, 'lib', 'index.js')], {
|
||||
env: { ...process.env, DSH_HOME: homeVal ?? '' },
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
})
|
||||
if (r.error) { fail(name, 'spawn error: ' + r.error.message); return null }
|
||||
if (r.status !== 0) { fail(name, 'exit=' + r.status + ' stderr=' + (r.stderr || '').slice(0, 400)); return null }
|
||||
return JSON.parse(r.stdout.trim().split('\n').pop())
|
||||
} finally {
|
||||
rmSync(isoDir, { recursive: true, force: true })
|
||||
if (home) rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
// —— A+B. 官方分支(测试环境明确准备官方夹具)+ BOM 容忍 ——
|
||||
{
|
||||
const out = runChild(undefined, 'A', true)
|
||||
if (!out) fail('A.官方路径', 'child did not return')
|
||||
else {
|
||||
if (out.status !== 200) fail('A.官方路径', 'status=' + out.status + ' body=' + out.body.slice(0, 120))
|
||||
else {
|
||||
try {
|
||||
const parsed = JSON.parse(out.body)
|
||||
if (parsed.tables?.workspaces?.['fake-MARKER_A']?.title !== 'MARKER_A') fail('A.官方路径', 'content mismatch: ' + out.body.slice(0, 160))
|
||||
} catch { fail('A.官方路径', 'body not JSON: ' + out.body.slice(0, 160)) }
|
||||
}
|
||||
if (!out.stats) fail('A.官方路径', '__wtLoadProbeStats missing')
|
||||
else {
|
||||
if (out.stats.homeSource !== 'official') fail('A.官方路径', 'homeSource=' + out.stats.homeSource + ' (expect official; 场景 A 必须证明官方分支被实际调用)')
|
||||
if (out.stats.attempts > PROBE_BOUND) fail('A.官方路径', 'probe attempts=' + out.stats.attempts + ' > ' + PROBE_BOUND)
|
||||
}
|
||||
// BOM 容忍:在子进程里没法重写(缓存单次解析),这里再跑一次带 BOM 夹具文件的场景 B
|
||||
}
|
||||
// B. BOM:单独子进程,写入带 BOM 的 workspace.json
|
||||
const outB = (() => {
|
||||
const isoDir = mkdtempSync(join(tmpdir(), 'wt-home-iso-'))
|
||||
let home = null
|
||||
try {
|
||||
mkdirSync(join(isoDir, 'lib'), { recursive: true })
|
||||
copyFileSync(BUNDLE_SRC, join(isoDir, 'lib', 'index.js'))
|
||||
const pkgDir = join(isoDir, 'node_modules', '@deepseek-ai', 'dsh-home-paths')
|
||||
mkdirSync(join(pkgDir, 'lib'), { recursive: true })
|
||||
writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-home-paths', version: '0.1.2-rc.1', type: 'module', main: 'lib/index.js' }), 'utf8')
|
||||
writeFileSync(join(pkgDir, 'lib', 'index.js'), [
|
||||
"import { homedir } from 'node:os'",
|
||||
"import { join, resolve } from 'node:path'",
|
||||
"export function resolveDshHome(configured, env = process.env) {",
|
||||
" const fromEnv = env['DSH_HOME']",
|
||||
" return resolve(configured ?? (fromEnv !== undefined && fromEnv.trim().length > 0 ? fromEnv : join(homedir(), '.dsh')))",
|
||||
"}",
|
||||
].join('\n'), 'utf8')
|
||||
home = makeHome('MARKER_B')
|
||||
const file = join(home, 'storages', 'workspace.json')
|
||||
writeFileSync(file, '\uFEFF' + readFileSync(file, 'utf8'), 'utf8')
|
||||
const script = [
|
||||
"import { pathToFileURL } from 'node:url'",
|
||||
"const mod = await import(pathToFileURL(process.argv[1]).href)",
|
||||
"const routes = []",
|
||||
"const fakeCtx = { webServer: { register: (r) => { routes.push(r) } }, effect: () => {}, logger: { warn() {}, info() {} } }",
|
||||
"mod.apply(fakeCtx)",
|
||||
"const route = routes.find((r) => r.path === '/api/worktable/workspaces')",
|
||||
"let status = 0; let body = ''",
|
||||
"const res = { writeHead(s) { status = s }, end(b) { body = String(b) } }",
|
||||
"await route.handler({}, res)",
|
||||
"console.log(JSON.stringify({ status, body: body.slice(0, 500) }))",
|
||||
].join('\n')
|
||||
const r = spawnSync(process.execPath, ['--input-type=module', '-e', script, join(isoDir, 'lib', 'index.js')], {
|
||||
env: { ...process.env, DSH_HOME: home },
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
})
|
||||
if (r.error) { fail('B.BOM', 'spawn error: ' + r.error.message); return null }
|
||||
if (r.status !== 0) { fail('B.BOM', 'exit=' + r.status + ' stderr=' + (r.stderr || '').slice(0, 400)); return null }
|
||||
return JSON.parse(r.stdout.trim().split('\n').pop())
|
||||
} finally {
|
||||
rmSync(isoDir, { recursive: true, force: true })
|
||||
if (home) rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})()
|
||||
if (outB) {
|
||||
let parsed = null
|
||||
try { parsed = JSON.parse(outB.body) } catch {}
|
||||
if (outB.status !== 200 || !parsed || parsed.tables?.workspaces?.['fake-MARKER_B']?.title !== 'MARKER_B') fail('B.BOM', 'status=' + outB.status + ' body=' + outB.body.slice(0, 160))
|
||||
}
|
||||
if (!failures.some((f) => f.startsWith('A.')) && !failures.some((f) => f.startsWith('B.'))) ok('A+B. 官方分支(夹具验证 homeSource=official + 有界探测)+ BOM 容忍')
|
||||
}
|
||||
|
||||
// —— C. 兜底分支:官方包不可解析 → baseDshHome,且探测次数有界(循环回归) ——
|
||||
{
|
||||
const out = runChild(undefined, 'C')
|
||||
if (!out) fail('C.兜底路径', 'child did not return')
|
||||
else {
|
||||
if (out.status !== 200) fail('C.兜底路径', 'status=' + out.status + ' body=' + out.body.slice(0, 200))
|
||||
if (!out.body.includes('MARKER_C')) fail('C.兜底路径', 'content mismatch: ' + out.body.slice(0, 200))
|
||||
if (!out.stats) fail('C.兜底路径', '__wtLoadProbeStats missing')
|
||||
else {
|
||||
if (out.stats.homeSource !== 'fallback') fail('C.兜底路径', 'homeSource=' + out.stats.homeSource + ' (expect fallback)')
|
||||
if (out.stats.attempts > PROBE_BOUND) fail('C.兜底路径', 'probe attempts=' + out.stats.attempts + ' > ' + PROBE_BOUND + '(疑似循环重入)')
|
||||
}
|
||||
}
|
||||
if (!failures.some((f) => f.startsWith('C.'))) ok('C. 官方包不可解析 → baseDshHome 兜底(homeSource=fallback + 探测有界 + 内容正确)')
|
||||
}
|
||||
|
||||
// —— D/E/F. ~ / ~/ / ~\ 前缀展开 + 相对路径:完整绝对路径断言 ——
|
||||
{
|
||||
const cases = [
|
||||
['D.波浪号', '~/__wt_probe_tilde__'],
|
||||
['E.相对路径', '__wt_probe_rel__'],
|
||||
['F.反斜杠波浪号', '~' + String.fromCharCode(92) + '__wt_probe_bs__'],
|
||||
]
|
||||
for (const [label, envVal] of cases) {
|
||||
const out = runChild(envVal, label.replace(/^\w\./, ''))
|
||||
if (!out) { fail(label, 'child did not return'); continue }
|
||||
if (out.status !== 404) { fail(label, 'expect 404, got ' + out.status + ' ' + out.body.slice(0, 120)); continue }
|
||||
if (out.fullMatch !== true) fail(label, 'error missing FULL path: expected ' + out.expectedFull + ' body=' + out.body.slice(0, 200))
|
||||
}
|
||||
if (!failures.some((f) => /^[DEF]\./.test(f))) ok('D+E+F. ~ 与 ~/ 与 ~\\ 前缀展开 + 相对路径按 cwd 解析(完整绝对路径断言)')
|
||||
}
|
||||
|
||||
console.log('all server-home tests passed: ' + pass + ' scenarios, ' + failures.length + ' failures')
|
||||
process.exitCode = failures.length > 0 ? 1 : 0
|
||||
Reference in New Issue
Block a user