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:
2026-10-20 11:17:34 +08:00
committed by trae-solo-agent
parent 8052bf5781
commit a1fff482bf
20 changed files with 251 additions and 48 deletions
+36
View File
@@ -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
+64
View File
@@ -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
+5 -1
View File
@@ -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)
+12 -1
View File
@@ -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 () => {
+7 -3
View File
@@ -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) => ({
+3 -1
View File
@@ -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 = ''
+5 -1
View File
@@ -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 = ''
+9 -5
View File
@@ -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 () => {
+7 -4
View File
@@ -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' } },
+8 -2
View File
@@ -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 () => {
+2 -2
View File
@@ -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',
+2 -1
View File
@@ -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()
+26 -12
View File
@@ -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 })
}
})
+9 -1
View File
@@ -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')
+3 -1
View File
@@ -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
View File
@@ -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'),