host-loader.spec.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  2. import { build } from 'esbuild'
  3. import { execFile } from 'node:child_process'
  4. import * as fs from 'node:fs'
  5. import * as path from 'node:path'
  6. import * as os from 'node:os'
  7. import { promisify } from 'node:util'
  8. import { removeSync } from '../../core/src/repo/remove'
  9. import { loadSourceHost } from '../scripts/dsh-source.mjs'
  10. import { linkFixtureDependencies } from './fixtures/fixture-dependencies.mjs'
  11. const run = promisify(execFile)
  12. const packageRoot = path.resolve(__dirname, '..')
  13. const checks = [
  14. '真实 Loader 与基线版本',
  15. '主 Agent 工具与全局隔离',
  16. '子 Agent 工具拒绝与真实 never 策略',
  17. '真实问答拒绝受委派调用',
  18. '作者裁决与本次调用绑定',
  19. 'Loader 卸载重载等价',
  20. '随包技能发现、资源、覆盖优先级与卸载重载',
  21. '运行时归属优先于历史 origin',
  22. '同一会话恢复后重新装机',
  23. '问答依赖卸载与重载',
  24. '审批依赖晚到仍拒绝子 Agent',
  25. '真实文件工具动作授权与审计',
  26. '文件动作预取消与等待中取消',
  27. '作者裁决预取消与迟到批准',
  28. '工作区注册、别名与非法 cwd',
  29. '裁决服务失败不写真源',
  30. '真实提案与补偿提交',
  31. '七类设计文件复用原生观察与 design 提交',
  32. '作者保存和落盘前并发修改使旧观察失效',
  33. '读观察按会话隔离且普通嵌套调用不能绕过门禁',
  34. '原生写入后提交失败可原样重跑且不重复升版',
  35. '多文件改稿保留事务且不拆成原生单文件写入',
  36. '纯处置保留证据、拒绝旧记录并允许上游保留',
  37. '记忆目录会话开始快照与选书目录',
  38. '补充原文预览保存、恢复审读与源变化失效',
  39. '最小导出合集清单、范围冲突与确定重跑',
  40. '当前宿主中文候选生成编辑确认与失败恢复',
  41. '定稿检索真实权限、关键词与原文定位',
  42. '独立嵌入提供方真实 HTTP 混合检索与配置刷新',
  43. '嵌入提供方卸载、晚到与取消不污染缓存',
  44. '宿主场景模型、独立缓存与可选重排完整接线',
  45. '工作区依赖重启交回旧根',
  46. '原生文件工具对子 Agent 的边界',
  47. '工具服务重启后重新装机',
  48. '原生对话恢复后继续',
  49. ] as const
  50. interface LoaderReport {
  51. node: string
  52. checks: Record<string, { ok: boolean; error?: string }>
  53. [key: string]: unknown
  54. }
  55. let root: string
  56. let report: LoaderReport
  57. beforeAll(async () => {
  58. root = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-loader-'))
  59. const workspace = path.join(root, 'workspace')
  60. fs.mkdirSync(workspace)
  61. const source = process.env['WEBNOVEL_DSH_SOURCE']
  62. if (source === undefined) {
  63. linkFixtureDependencies(packageRoot, root)
  64. } else {
  65. const host = loadSourceHost(source)
  66. fs.mkdirSync(path.join(root, 'node_modules', '@deepseek-ai'), { recursive: true })
  67. for (const name of ['cordis', 'dsh-tools', 'dsh-llm', 'dsh-credentials', 'dsh-skill-filesystem']) {
  68. fs.symlinkSync(host.get(`@deepseek-ai/${name}`).directory, path.join(root, 'node_modules', '@deepseek-ai', name), 'junction')
  69. }
  70. }
  71. const outfile = path.join(root, 'lib', 'webnovel.mjs')
  72. fs.cpSync(path.join(packageRoot, 'skills'), path.join(root, 'skills'), { recursive: true })
  73. await build({
  74. entryPoints: {
  75. 'lib/webnovel': path.join(packageRoot, 'src', 'index.ts'),
  76. 'native-write-core': path.join(packageRoot, 'tests/fixtures/native-write-core.ts'),
  77. 'embedding-provider': path.join(packageRoot, '../embedding-provider/src/index.ts'),
  78. },
  79. outdir: root,
  80. outExtension: { '.js': '.mjs' },
  81. bundle: true,
  82. platform: 'node',
  83. target: 'node20',
  84. format: 'esm',
  85. external: ['@deepseek-ai/cordis', '@deepseek-ai/dsh-tools', '@deepseek-ai/dsh-llm', '@deepseek-ai/dsh-credentials', '@deepseek-ai/dsh-skill-filesystem', 'node:*'],
  86. legalComments: 'none',
  87. banner: { js: "import { createRequire as __createRequire } from 'node:module'\nconst require = __createRequire(import.meta.url)" },
  88. })
  89. const result = await run(process.execPath, [
  90. path.join(packageRoot, 'tests', 'fixtures', 'host-loader.mjs'), root, outfile,
  91. ], {
  92. cwd: workspace,
  93. env: { ...process.env, DSH_HOME: path.join(root, '.dsh'), DSH_AGENTS_HOME: path.join(root, '.agents'), DSH_TELEMETRY_DISABLED: '1' },
  94. timeout: 60_000,
  95. windowsHide: true,
  96. maxBuffer: 2 * 1024 * 1024,
  97. })
  98. report = JSON.parse(fs.readFileSync(path.join(root, 'report.json'), 'utf8')) as LoaderReport
  99. const reportPath = process.env['WEBNOVEL_LOADER_REPORT']
  100. if (reportPath !== undefined) fs.writeFileSync(path.resolve(reportPath), JSON.stringify({ ...report, stdout: result.stdout, stderr: result.stderr }, null, 2))
  101. expect(result.stderr).not.toContain('UNHANDLED')
  102. }, 70_000)
  103. afterAll(() => {
  104. if (root === undefined) return
  105. // removeSync uses lstat and unlinks package junctions without following them.
  106. removeSync(root)
  107. })
  108. describe('真实 DSH Loader 验收(独立进程)', () => {
  109. for (const name of checks) {
  110. it(name, () => {
  111. expect(report.checks[name], report.checks[name]?.error).toEqual({ ok: true })
  112. })
  113. }
  114. })