diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9f9927c --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/macos-verification.yml b/.github/workflows/macos-verification.yml new file mode 100644 index 0000000..1de47a6 --- /dev/null +++ b/.github/workflows/macos-verification.yml @@ -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 diff --git a/scripts/generate-openapi.mjs b/scripts/generate-openapi.mjs index e13539f..89a9946 100644 --- a/scripts/generate-openapi.mjs +++ b/scripts/generate-openapi.mjs @@ -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) diff --git a/tests/server/agent-bridge-manager.test.ts b/tests/server/agent-bridge-manager.test.ts index 94c219a..1288504 100644 --- a/tests/server/agent-bridge-manager.test.ts +++ b/tests/server/agent-bridge-manager.test.ts @@ -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 () => { diff --git a/tests/server/gateway-respawn.test.ts b/tests/server/gateway-respawn.test.ts index f591401..aa49af1 100644 --- a/tests/server/gateway-respawn.test.ts +++ b/tests/server/gateway-respawn.test.ts @@ -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) diff --git a/tests/server/group-chat-history-window.test.ts b/tests/server/group-chat-history-window.test.ts index c615bbe..4110e24 100644 --- a/tests/server/group-chat-history-window.test.ts +++ b/tests/server/group-chat-history-window.test.ts @@ -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) => ({ diff --git a/tests/server/hermes-plugins-config.test.ts b/tests/server/hermes-plugins-config.test.ts index c2c47dc..0d9ff1c 100644 --- a/tests/server/hermes-plugins-config.test.ts +++ b/tests/server/hermes-plugins-config.test.ts @@ -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 = '' diff --git a/tests/server/hermes-plugins-env.test.ts b/tests/server/hermes-plugins-env.test.ts index e82a206..6899527 100644 --- a/tests/server/hermes-plugins-env.test.ts +++ b/tests/server/hermes-plugins-env.test.ts @@ -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 = '' diff --git a/tests/server/media-controller.test.ts b/tests/server/media-controller.test.ts index cbe0fa9..4d53c19 100644 --- a/tests/server/media-controller.test.ts +++ b/tests/server/media-controller.test.ts @@ -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 () => { diff --git a/tests/server/model-catalog-cache.test.ts b/tests/server/model-catalog-cache.test.ts index aece17c..d9674e9 100644 --- a/tests/server/model-catalog-cache.test.ts +++ b/tests/server/model-catalog-cache.test.ts @@ -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' } }, diff --git a/tests/server/no-legacy-brand.test.ts b/tests/server/no-legacy-brand.test.ts index d8f74d1..19cab2f 100644 --- a/tests/server/no-legacy-brand.test.ts +++ b/tests/server/no-legacy-brand.test.ts @@ -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) } diff --git a/tests/server/profile-delete-managed-gateway.test.ts b/tests/server/profile-delete-managed-gateway.test.ts index 8ecb02c..b729998 100644 --- a/tests/server/profile-delete-managed-gateway.test.ts +++ b/tests/server/profile-delete-managed-gateway.test.ts @@ -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' diff --git a/tests/server/run-chat-bridge-final-context.test.ts b/tests/server/run-chat-bridge-final-context.test.ts index 552b76a..004c3a1 100644 --- a/tests/server/run-chat-bridge-final-context.test.ts +++ b/tests/server/run-chat-bridge-final-context.test.ts @@ -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, }), ) }) diff --git a/tests/server/safe-file-store-backup-fallback.test.ts b/tests/server/safe-file-store-backup-fallback.test.ts index 030885d..42bb008 100644 --- a/tests/server/safe-file-store-backup-fallback.test.ts +++ b/tests/server/safe-file-store-backup-fallback.test.ts @@ -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 () => { diff --git a/tests/server/sessions-controller.test.ts b/tests/server/sessions-controller.test.ts index 89dbca1..44c2014 100644 --- a/tests/server/sessions-controller.test.ts +++ b/tests/server/sessions-controller.test.ts @@ -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', diff --git a/tests/server/sessions-db.test.ts b/tests/server/sessions-db.test.ts index fc0eeb3..ee74210 100644 --- a/tests/server/sessions-db.test.ts +++ b/tests/server/sessions-db.test.ts @@ -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() diff --git a/tests/server/skills-controller.test.ts b/tests/server/skills-controller.test.ts index dcc9d55..ca89300 100644 --- a/tests/server/skills-controller.test.ts +++ b/tests/server/skills-controller.test.ts @@ -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 }) } }) diff --git a/tests/server/workflow-manager.test.ts b/tests/server/workflow-manager.test.ts index ccb673d..6972099 100644 --- a/tests/server/workflow-manager.test.ts +++ b/tests/server/workflow-manager.test.ts @@ -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') diff --git a/tests/server/write-gate-service.test.ts b/tests/server/write-gate-service.test.ts index efdd142..39e8d0e 100644 --- a/tests/server/write-gate-service.test.ts +++ b/tests/server/write-gate-service.test.ts @@ -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', diff --git a/vitest.config.ts b/vitest.config.ts index eefa362..ed94205 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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'),