make the test suite and openapi generator platform-independent
Windows checkouts turned LF into CRLF (core.autocrlf=true), and several tests plus the openapi generator hardcoded LF or POSIX path separators. The result was a suite that was green on Linux CI but red on Windows, and an openapi.json whose /api/hermes/* routes silently disappeared when generated on Windows. Pin the repository to LF via .gitattributes, strip shebangs before Vite's SSR transform, and key route files by their posix path. Add a manually triggered macOS verification workflow so the same checks run on a self-hosted macOS runner.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# 统一行尾:仓库内的文本文件一律 LF。
|
||||
#
|
||||
# 为什么需要:Windows 上 git 的 core.autocrlf 默认是 true,checkout 会把 LF
|
||||
# 转成 CRLF。任何「按行读文件」的测试或脚本如果写死 '\n' 分割,就只会在
|
||||
# Windows 上失败,而 Linux CI 上是绿的 —— 这种平台差异很难被注意到。
|
||||
# 固定 LF 之后,Windows 和 Linux 拿到的是同一份字节,行为一致。
|
||||
#
|
||||
# 规则:
|
||||
# * text=auto —— 让 git 自行判断是不是文本
|
||||
# * eol=lf —— 无论平台,checkout 时都写成 LF
|
||||
# -text —— 二进制文件,禁止行尾转换
|
||||
|
||||
* text=auto eol=lf
|
||||
|
||||
# 二进制资源:不参与行尾转换
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.webp binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.eot binary
|
||||
*.mp3 binary
|
||||
*.mp4 binary
|
||||
*.wav binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.gz binary
|
||||
*.wasm binary
|
||||
|
||||
# 锁文件由 npm 生成,保持原样不转换
|
||||
package-lock.json -text
|
||||
@@ -0,0 +1,64 @@
|
||||
name: macOS Verification
|
||||
|
||||
# macOS is not covered by the Build or Playwright workflows: those run on
|
||||
# `ubuntu-latest`, and this repository's CI is Gitea Actions, which has no
|
||||
# hosted macOS runner. A `macos-latest` job added to the main Build workflow
|
||||
# would sit in the runner queue forever and hold the whole workflow in
|
||||
# "in progress", so macOS verification lives here instead and is triggered
|
||||
# explicitly.
|
||||
#
|
||||
# To run it, register a macOS runner with the `macos-latest` label in the
|
||||
# Gitea instance, then trigger this workflow from the Actions tab.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: macos-verification-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
# A self-hosted macOS runner. `macos-latest` is the label to register it
|
||||
# under; Gitea does not provide this runner itself.
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
# macOS ships a prebuilt node-pty, so no compiler is needed here. The
|
||||
# rebuild is still run because it is what the Build workflow does, and
|
||||
# because it keeps the two workflows comparing the same tree.
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci --ignore-scripts
|
||||
npm rebuild node-pty
|
||||
|
||||
- name: Check repository harness
|
||||
run: npm run harness:check
|
||||
|
||||
- name: Test with coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: End-to-end tests
|
||||
run: npm run test:e2e
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -95,7 +95,11 @@ function scanRoutes() {
|
||||
const hermesRouteFiles = readdirSync(hermesRoutesDir).filter(f => f.endsWith('.ts'))
|
||||
|
||||
for (const file of hermesRouteFiles) {
|
||||
const routePath = join('hermes', file)
|
||||
// 必须用 posix 风格的 key 去查 tagMappings:join() 在 Windows 上返回
|
||||
// 'hermes\kanban.ts',而映射表里的 key 是 'routes/hermes/kanban.ts',
|
||||
// 查不到就会把这个路由文件整个跳过 —— 结果是生成的 openapi.json 里
|
||||
// 所有 /api/hermes/* 路由全部消失。
|
||||
const routePath = `hermes/${file}`
|
||||
const tagInfo = tagMappings[`routes/${routePath}`]
|
||||
if (tagInfo) {
|
||||
scanRouteFile(join(hermesRoutesDir, file), tagInfo, paths)
|
||||
|
||||
@@ -147,8 +147,19 @@ describe('agent bridge manager command resolution', () => {
|
||||
it('uses an isolated default bridge endpoint while running under Vitest', async () => {
|
||||
const { DEFAULT_AGENT_BRIDGE_ENDPOINT } = await import('../../packages/server/src/services/hermes/agent-bridge/client')
|
||||
|
||||
expect(DEFAULT_AGENT_BRIDGE_ENDPOINT).toContain(`hermes-agent-bridge-test-${process.pid}`)
|
||||
// Vitest 下必须用「每个测试进程独立」的桥接端点,否则并行 worker 会抢同一个
|
||||
// 生产 bridge。Windows 没有 unix socket 路径,测试端点走带 pid 偏移的 TCP 端口;
|
||||
// POSIX 走带 pid 的 socket 文件。两种形态都要和生产的固定端点不同。
|
||||
expect(DEFAULT_AGENT_BRIDGE_ENDPOINT).not.toBe('ipc:///tmp/hermes-agent-bridge.sock')
|
||||
expect(DEFAULT_AGENT_BRIDGE_ENDPOINT).not.toBe('tcp://127.0.0.1:18765')
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const port = Number(DEFAULT_AGENT_BRIDGE_ENDPOINT.split(':').pop())
|
||||
expect(port).toBeGreaterThanOrEqual(28000)
|
||||
expect(port).toBeLessThanOrEqual(37999)
|
||||
} else {
|
||||
expect(DEFAULT_AGENT_BRIDGE_ENDPOINT).toContain(`hermes-agent-bridge-test-${process.pid}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('honors the bridge connect retry environment override', async () => {
|
||||
|
||||
@@ -205,7 +205,11 @@ describe('gateway-runner supervision', () => {
|
||||
startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/delete-me' })
|
||||
expect(fakeChildren).toHaveLength(1)
|
||||
|
||||
const retired = retireManagedGatewayForProfile('/tmp/delete-me', { timeoutMs: 5000 })
|
||||
// 固定 platform:停止网关的手段随平台不同(POSIX 走 child.kill('SIGTERM'),
|
||||
// Windows 走 taskkill 杀进程树),不固定的话这个用例会随运行平台变形。
|
||||
// Windows 分支由同文件末尾的 "kills the managed gateway process tree on
|
||||
// Windows shutdown" 单独覆盖。
|
||||
const retired = retireManagedGatewayForProfile('/tmp/delete-me', { timeoutMs: 5000, platform: 'linux' })
|
||||
expect(fakeChildren[0].killSignals).toEqual(['SIGTERM'])
|
||||
|
||||
fakeChildren[0].emit('exit', 0, 'SIGTERM')
|
||||
@@ -226,7 +230,7 @@ describe('gateway-runner supervision', () => {
|
||||
startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/shutdown-b' })
|
||||
expect(fakeChildren).toHaveLength(2)
|
||||
|
||||
const shutdown = shutdownManagedGateways({ timeoutMs: 5000 })
|
||||
const shutdown = shutdownManagedGateways({ timeoutMs: 5000, platform: 'linux' })
|
||||
|
||||
expect(fakeChildren[0].killSignals).toEqual(['SIGTERM'])
|
||||
expect(fakeChildren[1].killSignals).toEqual(['SIGTERM'])
|
||||
@@ -266,7 +270,7 @@ describe('gateway-runner supervision', () => {
|
||||
|
||||
startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/shutdown-d' })
|
||||
|
||||
const shutdown = shutdownManagedGateways({ timeoutMs: 1000 })
|
||||
const shutdown = shutdownManagedGateways({ timeoutMs: 1000, platform: 'linux' })
|
||||
expect(fakeChildren[0].killSignals).toEqual(['SIGTERM'])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
@@ -162,8 +162,12 @@ describe('group chat history windows', () => {
|
||||
expect(storage.getMessagesForContext('room-1')).toHaveLength(160)
|
||||
expect(latest?.totalTokens).toBe(expectedTotalTokens)
|
||||
expect(storage.getRoom('room-1')?.totalTokens).toBe(expectedTotalTokens)
|
||||
})
|
||||
}, 60_000)
|
||||
|
||||
// 这个用例故意写 501 条消息来触发 prune(保留 500 条)。而
|
||||
// saveMessageAndRefreshRoom 每写一条都要重读整份转录并重新计一遍 token,
|
||||
// 单测本身就是 O(n²):单独跑约 1.3s,全量并行时 CPU 被抢,很容易撞上
|
||||
// Vitest 默认的 5s 上限。这是重测试,不是卡死 —— 显式放宽超时。
|
||||
it('preserves snapshot summary tokens when the snapshot anchor was pruned from retained history', () => {
|
||||
const storage = groupServer.getStorage()
|
||||
storage.saveRoom('room-1', 'Room 1')
|
||||
@@ -189,7 +193,7 @@ describe('group chat history windows', () => {
|
||||
expect(storage.getContextSnapshot('room-1')?.lastMessageId).toBe('msg-1')
|
||||
expect(latest?.totalTokens).toBe(expectedTotalTokens)
|
||||
expect(storage.getRoom('room-1')?.totalTokens).toBe(expectedTotalTokens)
|
||||
})
|
||||
}, 60_000)
|
||||
|
||||
it('uses the full context transcript for the final AgentClient room estimate', async () => {
|
||||
const messages = Array.from({ length: 160 }, (_value, index) => ({
|
||||
|
||||
@@ -4,7 +4,9 @@ import { join } from 'path'
|
||||
import YAML from 'js-yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('Hermes plugin configuration', () => {
|
||||
// 假 python 是 #!/bin/sh 脚本,插件发现依赖 POSIX 的 shebang 解析链路。
|
||||
// Windows 上没有 shebang 执行语义,整组跳过而不是伪造结果。
|
||||
describe.skipIf(process.platform === 'win32')('Hermes plugin configuration', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
let tempDir = ''
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('Hermes plugin discovery environment', () => {
|
||||
// 这个用例的假 python 是一段 #!/bin/sh 脚本,验证的是「从 hermes 的 shebang
|
||||
// 解析出 venv python」这条 POSIX 链路。Windows 没有 shebang 执行语义,
|
||||
// venv 布局也是 .venv\Scripts\python.exe,这条链路在那边根本不存在,
|
||||
// 所以在 Windows 上整组跳过,而不是伪造一个假的 Windows 结果。
|
||||
describe.skipIf(process.platform === 'win32')('Hermes plugin discovery environment', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
let tempDir = ''
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { join } from 'path'
|
||||
import { join, resolve } from 'path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const originalWebUiHome = process.env.HERMES_WEB_UI_HOME
|
||||
@@ -21,10 +21,14 @@ describe('media controller', () => {
|
||||
process.env.HERMES_WEB_UI_HOME = '/tmp/hermes-ui-test-home'
|
||||
const { defaultImageOutputPath, defaultMediaOutputPath } = await import('../../packages/server/src/controllers/hermes/media')
|
||||
|
||||
expect(defaultMediaOutputPath('req_123')).toBe(join('/tmp/hermes-ui-test-home', 'media', 'req_123.mp4'))
|
||||
expect(defaultMediaOutputPath('bad/request:id')).toBe(join('/tmp/hermes-ui-test-home', 'media', 'bad_request_id.mp4'))
|
||||
expect(defaultImageOutputPath('img_123')).toBe(join('/tmp/hermes-ui-test-home', 'media', 'img_123.png'))
|
||||
expect(defaultImageOutputPath('bad/request:id', 1)).toBe(join('/tmp/hermes-ui-test-home', 'media', 'bad_request_id-2.png'))
|
||||
// 生产侧 getWebUiHome() 用 resolve() 落地 appHome,Windows 上 resolve('/tmp/x')
|
||||
// = 'D:\tmp\x',而 join('/tmp/x') = '\tmp\x'(join 不补盘符)。断言必须跟着
|
||||
// 生产语义走,否则只在 Windows 上红、Linux CI 上却是绿的。
|
||||
const mediaRoot = join(resolve('/tmp/hermes-ui-test-home'), 'media')
|
||||
expect(defaultMediaOutputPath('req_123')).toBe(join(mediaRoot, 'req_123.mp4'))
|
||||
expect(defaultMediaOutputPath('bad/request:id')).toBe(join(mediaRoot, 'bad_request_id.mp4'))
|
||||
expect(defaultImageOutputPath('img_123')).toBe(join(mediaRoot, 'img_123.png'))
|
||||
expect(defaultImageOutputPath('bad/request:id', 1)).toBe(join(mediaRoot, 'bad_request_id-2.png'))
|
||||
})
|
||||
|
||||
it('generates images through the requested configured custom provider', async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { join } from 'path'
|
||||
|
||||
const {
|
||||
mockReadFile,
|
||||
@@ -145,8 +146,10 @@ describe('model catalog cache', () => {
|
||||
mockGlobalFetch.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) })
|
||||
vi.stubGlobal('fetch', mockGlobalFetch)
|
||||
mockReadFile.mockImplementation(async (path: string) => {
|
||||
if (path === '/hermes/default/.env') return 'OPENROUTER_API_KEY=default-openrouter\n'
|
||||
if (path === '/hermes/team/.env') {
|
||||
// 生产侧用 join(profileDir, '.env') 拼路径,Windows 上是反斜杠,
|
||||
// 断言必须用 join() 才能跨平台对上。
|
||||
if (path === join('/hermes/default', '.env')) return 'OPENROUTER_API_KEY=default-openrouter\n'
|
||||
if (path === join('/hermes/team', '.env')) {
|
||||
return [
|
||||
'OPENROUTER_API_KEY=team-openrouter',
|
||||
'DEEPSEEK_API_KEY=team-deepseek',
|
||||
@@ -240,8 +243,8 @@ describe('model catalog cache', () => {
|
||||
return []
|
||||
})
|
||||
mockReadFile.mockImplementation(async (path: string) => {
|
||||
if (path === '/hermes/default/.env') return ''
|
||||
if (path === '/hermes/default/auth.json') {
|
||||
if (path === join('/hermes/default', '.env')) return ''
|
||||
if (path === join('/hermes/default', 'auth.json')) {
|
||||
return JSON.stringify({
|
||||
providers: {
|
||||
'openai-codex': { tokens: { access_token: 'codex-token' } },
|
||||
|
||||
@@ -264,8 +264,14 @@ describe('legacy brand removal (ekko / hermes-web-ui / hermes-studio)', () => {
|
||||
const marker = `const ${name} = [`
|
||||
const start = harness.indexOf(marker)
|
||||
expect(start, `${HARNESS_SCRIPT} 里找不到 ${name},本测试需要同步更新`).toBeGreaterThan(-1)
|
||||
const end = harness.indexOf('\n]\n', start)
|
||||
expect(end, `${HARNESS_SCRIPT} 里的 ${name} 数组没有正确闭合`).toBeGreaterThan(start)
|
||||
// 行尾不能写死 '\n]':Windows 上 checkout 会把 LF 转成 CRLF
|
||||
// (core.autocrlf=true),写死 LF 的话这个测试只会在 Windows 上红,
|
||||
// Linux CI 上却是绿的。仓库的 .gitattributes 已把行尾统一成 LF,但
|
||||
// 在它被加进来之前 checkout 过的工作区仍留着 CRLF,所以这里两种都要认。
|
||||
const rest = harness.slice(start)
|
||||
const closeMatch = /\r?\n\]/.exec(rest)
|
||||
expect(closeMatch, `${HARNESS_SCRIPT} 里的 ${name} 数组没有正确闭合`).not.toBeNull()
|
||||
const end = start + closeMatch!.index
|
||||
return harness.slice(start + marker.length - 1, end + 2)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from 'path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
|
||||
class FakeChild extends EventEmitter {
|
||||
pid: number
|
||||
@@ -39,6 +40,7 @@ afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetModules()
|
||||
process.env = { ...originalEnv }
|
||||
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
|
||||
fakeChildren = []
|
||||
})
|
||||
|
||||
@@ -46,6 +48,11 @@ describe('profile delete managed gateway lifecycle', () => {
|
||||
it('reproduces #1633 and proves delete prep suppresses managed respawn', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetModules()
|
||||
// 固定 platform:停止网关的手段随平台不同(POSIX 走 child.kill('SIGTERM'),
|
||||
// Windows 走 taskkill 杀进程树)。不固定的话这个用例在 Windows 上会因为
|
||||
// taskkill 是异步的而读不到 killSignals,而且会真的拿假 pid 去杀进程。
|
||||
// Windows 分支由 gateway-respawn.test.ts 单独覆盖。
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
const home = await mkdtemp(join(tmpdir(), 'wui-1633-'))
|
||||
process.env.HERMES_HOME = home
|
||||
process.env.HERMES_BIN = '/usr/bin/hermes'
|
||||
|
||||
@@ -102,6 +102,10 @@ vi.mock('../../packages/server/src/middleware/user-auth', () => ({
|
||||
issueModelRunJwt: issueModelRunJwtMock,
|
||||
}))
|
||||
|
||||
// 生产侧用 join(getProfileDir(profile), 'workspace') 拼默认工作区,Windows 上是
|
||||
// 反斜杠分隔。断言必须跟着走 join,否则只有 Linux 是绿的。
|
||||
const DEFAULT_PROFILE_WORKSPACE = join('/tmp/hermes-bridge-final-context/default', 'workspace')
|
||||
|
||||
function makeSocket() {
|
||||
return {
|
||||
connected: true,
|
||||
@@ -292,7 +296,7 @@ describe('bridge run final context usage', () => {
|
||||
{
|
||||
model: 'gpt-test',
|
||||
provider: 'openai',
|
||||
workspace: '/tmp/hermes-bridge-final-context/default/workspace',
|
||||
workspace: DEFAULT_PROFILE_WORKSPACE,
|
||||
},
|
||||
)
|
||||
expect(bridge.contextEstimate.mock.calls[0][2]).toContain('system prompt')
|
||||
@@ -660,7 +664,7 @@ describe('bridge run final context usage', () => {
|
||||
expect(createSessionMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 'session-1',
|
||||
source: 'global_agent',
|
||||
workspace: '/tmp/hermes-bridge-final-context/default/workspace',
|
||||
workspace: DEFAULT_PROFILE_WORKSPACE,
|
||||
}))
|
||||
expect(state.source).toBe('global_agent')
|
||||
})
|
||||
@@ -1104,7 +1108,7 @@ describe('bridge run final context usage', () => {
|
||||
'default',
|
||||
expect.objectContaining({
|
||||
storage_message: '/plan build the feature',
|
||||
workspace: '/tmp/hermes-bridge-final-context/default/workspace',
|
||||
workspace: DEFAULT_PROFILE_WORKSPACE,
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1229,7 +1233,7 @@ describe('bridge run final context usage', () => {
|
||||
'default',
|
||||
expect.objectContaining({
|
||||
storage_message: '[IMPORTANT: expanded skill prompt]',
|
||||
workspace: '/tmp/hermes-bridge-final-context/default/workspace',
|
||||
workspace: DEFAULT_PROFILE_WORKSPACE,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolve } from 'path'
|
||||
|
||||
const {
|
||||
mockCopyFile,
|
||||
@@ -41,14 +42,18 @@ describe('SafeFileStore backup fallback', () => {
|
||||
const { SafeFileStore } = await import('../../packages/server/src/services/safe-file-store')
|
||||
const store = new SafeFileStore()
|
||||
|
||||
// SafeFileStore.normalizePath() 走 resolve(),Windows 上 resolve('/tmp/config.yaml')
|
||||
// = 'D:\tmp\config.yaml'。断言必须按生产语义写死,否则只在 Windows 上红。
|
||||
const target = resolve('/tmp/config.yaml')
|
||||
|
||||
await store.writeText('/tmp/config.yaml', 'model:\n default: new\n', { backup: true })
|
||||
|
||||
expect(mockCopyFile).toHaveBeenCalledTimes(2)
|
||||
expect(mockCopyFile).toHaveBeenNthCalledWith(1, '/tmp/config.yaml', '/tmp/config.yaml.bak')
|
||||
expect(mockCopyFile.mock.calls[1][0]).toBe('/tmp/config.yaml')
|
||||
expect(mockCopyFile.mock.calls[1][1]).toMatch(/^\/tmp\/config\.yaml\.bak\.\d+\./)
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(expect.stringContaining('/tmp/config.yaml.tmp.'), 'model:\n default: new\n', 'utf-8')
|
||||
expect(mockRename).toHaveBeenCalledWith(expect.stringContaining('/tmp/config.yaml.tmp.'), '/tmp/config.yaml')
|
||||
expect(mockCopyFile).toHaveBeenNthCalledWith(1, target, `${target}.bak`)
|
||||
expect(mockCopyFile.mock.calls[1][0]).toBe(target)
|
||||
expect(mockCopyFile.mock.calls[1][1].startsWith(`${target}.bak.`)).toBe(true)
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(expect.stringContaining(`${target}.tmp.`), 'model:\n default: new\n', 'utf-8')
|
||||
expect(mockRename).toHaveBeenCalledWith(expect.stringContaining(`${target}.tmp.`), target)
|
||||
})
|
||||
|
||||
it('preserves explicit backup path failures', async () => {
|
||||
|
||||
@@ -1302,7 +1302,7 @@ describe('session conversations controller', () => {
|
||||
expect(localUpdateSessionMock).toHaveBeenCalledWith('session-1', {
|
||||
model: 'grok-4',
|
||||
provider: 'xai',
|
||||
workspace: '/tmp/hermes-test/default/workspace',
|
||||
workspace: join('/tmp/hermes-test/default', 'workspace'),
|
||||
})
|
||||
expect(bridgeSwitchSessionModelMock).not.toHaveBeenCalled()
|
||||
expect(ctx.body).toEqual({ ok: true })
|
||||
@@ -1331,7 +1331,7 @@ describe('session conversations controller', () => {
|
||||
expect(localUpdateSessionMock).toHaveBeenCalledWith('session-1', {
|
||||
model: 'claude-sonnet-4-6',
|
||||
provider: 'claude-oauth',
|
||||
workspace: '/tmp/hermes-test/travel/workspace',
|
||||
workspace: join('/tmp/hermes-test/travel', 'workspace'),
|
||||
})
|
||||
expect(bridgeSwitchSessionModelMock).toHaveBeenCalledWith(
|
||||
'session-1',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { join } from 'path'
|
||||
|
||||
const allMock = vi.fn()
|
||||
const indexAllMock = vi.fn()
|
||||
@@ -71,7 +72,7 @@ describe('session DB summaries', () => {
|
||||
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||
const rows = await mod.listSessionSummaries(undefined, 50)
|
||||
|
||||
expect(databaseSyncMock).toHaveBeenCalledWith('/tmp/hermes-profile/state.db', { open: true, readOnly: true })
|
||||
expect(databaseSyncMock).toHaveBeenCalledWith(join('/tmp/hermes-profile', 'state.db'), { open: true, readOnly: true })
|
||||
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining("s.source != 'tool'"))
|
||||
expect(allMock).toHaveBeenCalledWith(200)
|
||||
expect(closeMock).toHaveBeenCalled()
|
||||
|
||||
@@ -35,6 +35,26 @@ async function loadController() {
|
||||
return import('../../packages/server/src/controllers/hermes/skills')
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「用户主目录」指向临时目录,并返回恢复函数。
|
||||
*
|
||||
* Node 的 os.homedir() 在 Windows 上优先读 USERPROFILE,在 POSIX 上优先读 HOME。
|
||||
* 只设 HOME 会让 Windows 落到真实用户目录,夹具就找不到,测试只在 Linux 上是绿的。
|
||||
* 两个都设,跨平台行为一致。
|
||||
*/
|
||||
function useHomeDir(root: string): () => void {
|
||||
const previousHome = process.env.HOME
|
||||
const previousUserProfile = process.env.USERPROFILE
|
||||
process.env.HOME = root
|
||||
process.env.USERPROFILE = root
|
||||
return () => {
|
||||
if (previousHome == null) delete process.env.HOME
|
||||
else process.env.HOME = previousHome
|
||||
if (previousUserProfile == null) delete process.env.USERPROFILE
|
||||
else process.env.USERPROFILE = previousUserProfile
|
||||
}
|
||||
}
|
||||
|
||||
function multipartBody(boundary: string, parts: Array<{ name: string; value: string; filename?: string; filenameStar?: string; contentType?: string }>): Buffer {
|
||||
const chunks: Buffer[] = []
|
||||
for (const part of parts) {
|
||||
@@ -200,7 +220,6 @@ describe('skills controller', () => {
|
||||
|
||||
it('lists Codex user and system skills for the codex target', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'hermes-ui-codex-skills-'))
|
||||
const previousHome = process.env.HOME
|
||||
const userSkillDir = join(root, '.agents', 'skills', 'user-skill')
|
||||
const systemSkillDir = join(root, '.codex', 'skills', '.system', 'system-skill')
|
||||
|
||||
@@ -208,7 +227,7 @@ describe('skills controller', () => {
|
||||
await mkdir(systemSkillDir, { recursive: true })
|
||||
await writeFile(join(userSkillDir, 'SKILL.md'), '# User Skill\nuser codex skill\n', 'utf-8')
|
||||
await writeFile(join(systemSkillDir, 'SKILL.md'), '# System Skill\nsystem codex skill\n', 'utf-8')
|
||||
process.env.HOME = root
|
||||
const restoreHome = useHomeDir(root)
|
||||
|
||||
try {
|
||||
const { list } = await loadController()
|
||||
@@ -227,21 +246,19 @@ describe('skills controller', () => {
|
||||
})
|
||||
expect(mockReadConfigYamlForProfile).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
if (previousHome == null) delete process.env.HOME
|
||||
else process.env.HOME = previousHome
|
||||
restoreHome()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reads Codex system skill details for the codex target', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'hermes-ui-codex-system-skill-'))
|
||||
const previousHome = process.env.HOME
|
||||
const systemSkillDir = join(root, '.codex', 'skills', '.system', 'imagegen')
|
||||
|
||||
await mkdir(join(systemSkillDir, 'references'), { recursive: true })
|
||||
await writeFile(join(systemSkillDir, 'SKILL.md'), '# Imagegen\nsystem image skill\n', 'utf-8')
|
||||
await writeFile(join(systemSkillDir, 'references', 'usage.md'), 'usage notes\n', 'utf-8')
|
||||
process.env.HOME = root
|
||||
const restoreHome = useHomeDir(root)
|
||||
mockListFilesRecursive.mockResolvedValue([
|
||||
{ path: 'SKILL.md', isDir: false },
|
||||
{ path: 'references/usage.md', isDir: false },
|
||||
@@ -271,8 +288,7 @@ describe('skills controller', () => {
|
||||
|
||||
expect(filesCtx.body).toEqual({ files: [{ path: 'references/usage.md', isDir: false }] })
|
||||
} finally {
|
||||
if (previousHome == null) delete process.env.HOME
|
||||
else process.env.HOME = previousHome
|
||||
restoreHome()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -477,7 +493,6 @@ describe('skills controller', () => {
|
||||
|
||||
it('updates Codex user skills but not Codex system skills', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'hermes-ui-update-codex-skill-'))
|
||||
const previousHome = process.env.HOME
|
||||
const userSkillDir = join(root, '.agents', 'skills', 'user-skill')
|
||||
const systemSkillDir = join(root, '.codex', 'skills', '.system', 'system-skill')
|
||||
|
||||
@@ -485,7 +500,7 @@ describe('skills controller', () => {
|
||||
await mkdir(systemSkillDir, { recursive: true })
|
||||
await writeFile(join(userSkillDir, 'SKILL.md'), '# User Skill\n', 'utf-8')
|
||||
await writeFile(join(systemSkillDir, 'SKILL.md'), '# System Skill\n', 'utf-8')
|
||||
process.env.HOME = root
|
||||
const restoreHome = useHomeDir(root)
|
||||
|
||||
try {
|
||||
const { updateSkill } = await loadController()
|
||||
@@ -516,8 +531,7 @@ describe('skills controller', () => {
|
||||
expect(systemCtx.status).toBe(404)
|
||||
expect(systemCtx.body).toEqual({ error: 'Skill not found' })
|
||||
} finally {
|
||||
if (previousHome == null) delete process.env.HOME
|
||||
else process.env.HOME = previousHome
|
||||
restoreHome()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const chatRunMock = vi.hoisted(() => ({
|
||||
runAndWait: vi.fn(),
|
||||
@@ -22,6 +22,14 @@ vi.mock('../../packages/server/src/db/hermes/session-store', async (importOrigin
|
||||
})
|
||||
|
||||
describe('workflow manager', () => {
|
||||
beforeAll(async () => {
|
||||
// 建表只在 server bootstrap(packages/server/src/index.ts)里执行,单测不走
|
||||
// bootstrap。workflows 表能不能用,取决于别的测试文件有没有先建过它 ——
|
||||
// 这是顺序依赖的偶发失败。这里显式建表,让本文件自给自足。
|
||||
const { initAllHermesTables } = await import('../../packages/server/src/db/hermes/schemas')
|
||||
initAllHermesTables()
|
||||
})
|
||||
|
||||
it('returns a server-wide singleton instance', async () => {
|
||||
const { WorkflowManager, getWorkflowManager } = await import('../../packages/server/src/services/workflow-manager')
|
||||
|
||||
|
||||
@@ -117,7 +117,9 @@ describe('write gate service', () => {
|
||||
expect(isWriteGateSupported()).toBe(true)
|
||||
})
|
||||
|
||||
it('detects write approval support from a pip-installed runtime Python', async () => {
|
||||
// 假 python 是 #!/bin/sh 脚本,能力探测要真的把它跑起来。Windows 没有
|
||||
// shebang 执行语义,这个用例只在 POSIX 上有意义。
|
||||
it.skipIf(process.platform === 'win32')('detects write approval support from a pip-installed runtime Python', async () => {
|
||||
const fakePython = join(hermesHome, 'python')
|
||||
await writeFile(fakePython, [
|
||||
'#!/bin/sh',
|
||||
|
||||
+22
-2
@@ -1,9 +1,29 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { defineConfig, type Plugin } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
/**
|
||||
* Vite 的 SSR 变换用 `/^#!.*\n/` 识别 shebang,并把 import 声明提升到它前面。
|
||||
* 这个正则只在 LF 工作区成立:`.` 不匹配 `\r`,所以 CRLF checkout(Windows 默认
|
||||
* core.autocrlf=true)下 shebang 识别失败,import 被提升到 `#!/usr/bin/env node`
|
||||
* 之前,shebang 落到文件中部,运行期直接抛 "Invalid or unexpected token"。
|
||||
*
|
||||
* 统一在 SSR 变换之前剥掉 shebang:它只对「直接执行」有意义,测试里不需要。
|
||||
*/
|
||||
function stripShebang(): Plugin {
|
||||
return {
|
||||
name: 'hermes:strip-shebang',
|
||||
enforce: 'pre',
|
||||
transform(code, id) {
|
||||
if (!code.startsWith('#!')) return null
|
||||
const newline = code.indexOf('\n')
|
||||
return { code: newline === -1 ? '' : code.slice(newline + 1), map: null }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [stripShebang(), vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'packages/client/src'),
|
||||
|
||||
Reference in New Issue
Block a user