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'))
|
const hermesRouteFiles = readdirSync(hermesRoutesDir).filter(f => f.endsWith('.ts'))
|
||||||
|
|
||||||
for (const file of hermesRouteFiles) {
|
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}`]
|
const tagInfo = tagMappings[`routes/${routePath}`]
|
||||||
if (tagInfo) {
|
if (tagInfo) {
|
||||||
scanRouteFile(join(hermesRoutesDir, file), tagInfo, paths)
|
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 () => {
|
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')
|
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('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 () => {
|
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' })
|
startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/delete-me' })
|
||||||
expect(fakeChildren).toHaveLength(1)
|
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'])
|
expect(fakeChildren[0].killSignals).toEqual(['SIGTERM'])
|
||||||
|
|
||||||
fakeChildren[0].emit('exit', 0, 'SIGTERM')
|
fakeChildren[0].emit('exit', 0, 'SIGTERM')
|
||||||
@@ -226,7 +230,7 @@ describe('gateway-runner supervision', () => {
|
|||||||
startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/shutdown-b' })
|
startGatewayRunManaged('/usr/bin/hermes', { profileDir: '/tmp/shutdown-b' })
|
||||||
expect(fakeChildren).toHaveLength(2)
|
expect(fakeChildren).toHaveLength(2)
|
||||||
|
|
||||||
const shutdown = shutdownManagedGateways({ timeoutMs: 5000 })
|
const shutdown = shutdownManagedGateways({ timeoutMs: 5000, platform: 'linux' })
|
||||||
|
|
||||||
expect(fakeChildren[0].killSignals).toEqual(['SIGTERM'])
|
expect(fakeChildren[0].killSignals).toEqual(['SIGTERM'])
|
||||||
expect(fakeChildren[1].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' })
|
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'])
|
expect(fakeChildren[0].killSignals).toEqual(['SIGTERM'])
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(1000)
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
|
|||||||
@@ -162,8 +162,12 @@ describe('group chat history windows', () => {
|
|||||||
expect(storage.getMessagesForContext('room-1')).toHaveLength(160)
|
expect(storage.getMessagesForContext('room-1')).toHaveLength(160)
|
||||||
expect(latest?.totalTokens).toBe(expectedTotalTokens)
|
expect(latest?.totalTokens).toBe(expectedTotalTokens)
|
||||||
expect(storage.getRoom('room-1')?.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', () => {
|
it('preserves snapshot summary tokens when the snapshot anchor was pruned from retained history', () => {
|
||||||
const storage = groupServer.getStorage()
|
const storage = groupServer.getStorage()
|
||||||
storage.saveRoom('room-1', 'Room 1')
|
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(storage.getContextSnapshot('room-1')?.lastMessageId).toBe('msg-1')
|
||||||
expect(latest?.totalTokens).toBe(expectedTotalTokens)
|
expect(latest?.totalTokens).toBe(expectedTotalTokens)
|
||||||
expect(storage.getRoom('room-1')?.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 () => {
|
it('uses the full context transcript for the final AgentClient room estimate', async () => {
|
||||||
const messages = Array.from({ length: 160 }, (_value, index) => ({
|
const messages = Array.from({ length: 160 }, (_value, index) => ({
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { join } from 'path'
|
|||||||
import YAML from 'js-yaml'
|
import YAML from 'js-yaml'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
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 }
|
const originalEnv = { ...process.env }
|
||||||
let tempDir = ''
|
let tempDir = ''
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { tmpdir } from 'os'
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
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 }
|
const originalEnv = { ...process.env }
|
||||||
let tempDir = ''
|
let tempDir = ''
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { join } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const originalWebUiHome = process.env.HERMES_WEB_UI_HOME
|
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'
|
process.env.HERMES_WEB_UI_HOME = '/tmp/hermes-ui-test-home'
|
||||||
const { defaultImageOutputPath, defaultMediaOutputPath } = await import('../../packages/server/src/controllers/hermes/media')
|
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'))
|
// 生产侧 getWebUiHome() 用 resolve() 落地 appHome,Windows 上 resolve('/tmp/x')
|
||||||
expect(defaultMediaOutputPath('bad/request:id')).toBe(join('/tmp/hermes-ui-test-home', 'media', 'bad_request_id.mp4'))
|
// = 'D:\tmp\x',而 join('/tmp/x') = '\tmp\x'(join 不补盘符)。断言必须跟着
|
||||||
expect(defaultImageOutputPath('img_123')).toBe(join('/tmp/hermes-ui-test-home', 'media', 'img_123.png'))
|
// 生产语义走,否则只在 Windows 上红、Linux CI 上却是绿的。
|
||||||
expect(defaultImageOutputPath('bad/request:id', 1)).toBe(join('/tmp/hermes-ui-test-home', 'media', 'bad_request_id-2.png'))
|
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 () => {
|
it('generates images through the requested configured custom provider', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mockReadFile,
|
mockReadFile,
|
||||||
@@ -145,8 +146,10 @@ describe('model catalog cache', () => {
|
|||||||
mockGlobalFetch.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) })
|
mockGlobalFetch.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) })
|
||||||
vi.stubGlobal('fetch', mockGlobalFetch)
|
vi.stubGlobal('fetch', mockGlobalFetch)
|
||||||
mockReadFile.mockImplementation(async (path: string) => {
|
mockReadFile.mockImplementation(async (path: string) => {
|
||||||
if (path === '/hermes/default/.env') return 'OPENROUTER_API_KEY=default-openrouter\n'
|
// 生产侧用 join(profileDir, '.env') 拼路径,Windows 上是反斜杠,
|
||||||
if (path === '/hermes/team/.env') {
|
// 断言必须用 join() 才能跨平台对上。
|
||||||
|
if (path === join('/hermes/default', '.env')) return 'OPENROUTER_API_KEY=default-openrouter\n'
|
||||||
|
if (path === join('/hermes/team', '.env')) {
|
||||||
return [
|
return [
|
||||||
'OPENROUTER_API_KEY=team-openrouter',
|
'OPENROUTER_API_KEY=team-openrouter',
|
||||||
'DEEPSEEK_API_KEY=team-deepseek',
|
'DEEPSEEK_API_KEY=team-deepseek',
|
||||||
@@ -240,8 +243,8 @@ describe('model catalog cache', () => {
|
|||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
mockReadFile.mockImplementation(async (path: string) => {
|
mockReadFile.mockImplementation(async (path: string) => {
|
||||||
if (path === '/hermes/default/.env') return ''
|
if (path === join('/hermes/default', '.env')) return ''
|
||||||
if (path === '/hermes/default/auth.json') {
|
if (path === join('/hermes/default', 'auth.json')) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
providers: {
|
providers: {
|
||||||
'openai-codex': { tokens: { access_token: 'codex-token' } },
|
'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 marker = `const ${name} = [`
|
||||||
const start = harness.indexOf(marker)
|
const start = harness.indexOf(marker)
|
||||||
expect(start, `${HARNESS_SCRIPT} 里找不到 ${name},本测试需要同步更新`).toBeGreaterThan(-1)
|
expect(start, `${HARNESS_SCRIPT} 里找不到 ${name},本测试需要同步更新`).toBeGreaterThan(-1)
|
||||||
const end = harness.indexOf('\n]\n', start)
|
// 行尾不能写死 '\n]':Windows 上 checkout 会把 LF 转成 CRLF
|
||||||
expect(end, `${HARNESS_SCRIPT} 里的 ${name} 数组没有正确闭合`).toBeGreaterThan(start)
|
// (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)
|
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'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const originalEnv = { ...process.env }
|
const originalEnv = { ...process.env }
|
||||||
|
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||||
|
|
||||||
class FakeChild extends EventEmitter {
|
class FakeChild extends EventEmitter {
|
||||||
pid: number
|
pid: number
|
||||||
@@ -39,6 +40,7 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
process.env = { ...originalEnv }
|
process.env = { ...originalEnv }
|
||||||
|
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
|
||||||
fakeChildren = []
|
fakeChildren = []
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -46,6 +48,11 @@ describe('profile delete managed gateway lifecycle', () => {
|
|||||||
it('reproduces #1633 and proves delete prep suppresses managed respawn', async () => {
|
it('reproduces #1633 and proves delete prep suppresses managed respawn', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
vi.resetModules()
|
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-'))
|
const home = await mkdtemp(join(tmpdir(), 'wui-1633-'))
|
||||||
process.env.HERMES_HOME = home
|
process.env.HERMES_HOME = home
|
||||||
process.env.HERMES_BIN = '/usr/bin/hermes'
|
process.env.HERMES_BIN = '/usr/bin/hermes'
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ vi.mock('../../packages/server/src/middleware/user-auth', () => ({
|
|||||||
issueModelRunJwt: issueModelRunJwtMock,
|
issueModelRunJwt: issueModelRunJwtMock,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// 生产侧用 join(getProfileDir(profile), 'workspace') 拼默认工作区,Windows 上是
|
||||||
|
// 反斜杠分隔。断言必须跟着走 join,否则只有 Linux 是绿的。
|
||||||
|
const DEFAULT_PROFILE_WORKSPACE = join('/tmp/hermes-bridge-final-context/default', 'workspace')
|
||||||
|
|
||||||
function makeSocket() {
|
function makeSocket() {
|
||||||
return {
|
return {
|
||||||
connected: true,
|
connected: true,
|
||||||
@@ -292,7 +296,7 @@ describe('bridge run final context usage', () => {
|
|||||||
{
|
{
|
||||||
model: 'gpt-test',
|
model: 'gpt-test',
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
workspace: '/tmp/hermes-bridge-final-context/default/workspace',
|
workspace: DEFAULT_PROFILE_WORKSPACE,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
expect(bridge.contextEstimate.mock.calls[0][2]).toContain('system prompt')
|
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({
|
expect(createSessionMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
id: 'session-1',
|
id: 'session-1',
|
||||||
source: 'global_agent',
|
source: 'global_agent',
|
||||||
workspace: '/tmp/hermes-bridge-final-context/default/workspace',
|
workspace: DEFAULT_PROFILE_WORKSPACE,
|
||||||
}))
|
}))
|
||||||
expect(state.source).toBe('global_agent')
|
expect(state.source).toBe('global_agent')
|
||||||
})
|
})
|
||||||
@@ -1104,7 +1108,7 @@ describe('bridge run final context usage', () => {
|
|||||||
'default',
|
'default',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
storage_message: '/plan build the feature',
|
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',
|
'default',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
storage_message: '[IMPORTANT: expanded skill prompt]',
|
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 { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mockCopyFile,
|
mockCopyFile,
|
||||||
@@ -41,14 +42,18 @@ describe('SafeFileStore backup fallback', () => {
|
|||||||
const { SafeFileStore } = await import('../../packages/server/src/services/safe-file-store')
|
const { SafeFileStore } = await import('../../packages/server/src/services/safe-file-store')
|
||||||
const store = new SafeFileStore()
|
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 })
|
await store.writeText('/tmp/config.yaml', 'model:\n default: new\n', { backup: true })
|
||||||
|
|
||||||
expect(mockCopyFile).toHaveBeenCalledTimes(2)
|
expect(mockCopyFile).toHaveBeenCalledTimes(2)
|
||||||
expect(mockCopyFile).toHaveBeenNthCalledWith(1, '/tmp/config.yaml', '/tmp/config.yaml.bak')
|
expect(mockCopyFile).toHaveBeenNthCalledWith(1, target, `${target}.bak`)
|
||||||
expect(mockCopyFile.mock.calls[1][0]).toBe('/tmp/config.yaml')
|
expect(mockCopyFile.mock.calls[1][0]).toBe(target)
|
||||||
expect(mockCopyFile.mock.calls[1][1]).toMatch(/^\/tmp\/config\.yaml\.bak\.\d+\./)
|
expect(mockCopyFile.mock.calls[1][1].startsWith(`${target}.bak.`)).toBe(true)
|
||||||
expect(mockWriteFile).toHaveBeenCalledWith(expect.stringContaining('/tmp/config.yaml.tmp.'), 'model:\n default: new\n', 'utf-8')
|
expect(mockWriteFile).toHaveBeenCalledWith(expect.stringContaining(`${target}.tmp.`), 'model:\n default: new\n', 'utf-8')
|
||||||
expect(mockRename).toHaveBeenCalledWith(expect.stringContaining('/tmp/config.yaml.tmp.'), '/tmp/config.yaml')
|
expect(mockRename).toHaveBeenCalledWith(expect.stringContaining(`${target}.tmp.`), target)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('preserves explicit backup path failures', async () => {
|
it('preserves explicit backup path failures', async () => {
|
||||||
|
|||||||
@@ -1302,7 +1302,7 @@ describe('session conversations controller', () => {
|
|||||||
expect(localUpdateSessionMock).toHaveBeenCalledWith('session-1', {
|
expect(localUpdateSessionMock).toHaveBeenCalledWith('session-1', {
|
||||||
model: 'grok-4',
|
model: 'grok-4',
|
||||||
provider: 'xai',
|
provider: 'xai',
|
||||||
workspace: '/tmp/hermes-test/default/workspace',
|
workspace: join('/tmp/hermes-test/default', 'workspace'),
|
||||||
})
|
})
|
||||||
expect(bridgeSwitchSessionModelMock).not.toHaveBeenCalled()
|
expect(bridgeSwitchSessionModelMock).not.toHaveBeenCalled()
|
||||||
expect(ctx.body).toEqual({ ok: true })
|
expect(ctx.body).toEqual({ ok: true })
|
||||||
@@ -1331,7 +1331,7 @@ describe('session conversations controller', () => {
|
|||||||
expect(localUpdateSessionMock).toHaveBeenCalledWith('session-1', {
|
expect(localUpdateSessionMock).toHaveBeenCalledWith('session-1', {
|
||||||
model: 'claude-sonnet-4-6',
|
model: 'claude-sonnet-4-6',
|
||||||
provider: 'claude-oauth',
|
provider: 'claude-oauth',
|
||||||
workspace: '/tmp/hermes-test/travel/workspace',
|
workspace: join('/tmp/hermes-test/travel', 'workspace'),
|
||||||
})
|
})
|
||||||
expect(bridgeSwitchSessionModelMock).toHaveBeenCalledWith(
|
expect(bridgeSwitchSessionModelMock).toHaveBeenCalledWith(
|
||||||
'session-1',
|
'session-1',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
const allMock = vi.fn()
|
const allMock = vi.fn()
|
||||||
const indexAllMock = 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 mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||||
const rows = await mod.listSessionSummaries(undefined, 50)
|
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(prepareMock).toHaveBeenCalledWith(expect.stringContaining("s.source != 'tool'"))
|
||||||
expect(allMock).toHaveBeenCalledWith(200)
|
expect(allMock).toHaveBeenCalledWith(200)
|
||||||
expect(closeMock).toHaveBeenCalled()
|
expect(closeMock).toHaveBeenCalled()
|
||||||
|
|||||||
@@ -35,6 +35,26 @@ async function loadController() {
|
|||||||
return import('../../packages/server/src/controllers/hermes/skills')
|
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 {
|
function multipartBody(boundary: string, parts: Array<{ name: string; value: string; filename?: string; filenameStar?: string; contentType?: string }>): Buffer {
|
||||||
const chunks: Buffer[] = []
|
const chunks: Buffer[] = []
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
@@ -200,7 +220,6 @@ describe('skills controller', () => {
|
|||||||
|
|
||||||
it('lists Codex user and system skills for the codex target', async () => {
|
it('lists Codex user and system skills for the codex target', async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), 'hermes-ui-codex-skills-'))
|
const root = await mkdtemp(join(tmpdir(), 'hermes-ui-codex-skills-'))
|
||||||
const previousHome = process.env.HOME
|
|
||||||
const userSkillDir = join(root, '.agents', 'skills', 'user-skill')
|
const userSkillDir = join(root, '.agents', 'skills', 'user-skill')
|
||||||
const systemSkillDir = join(root, '.codex', 'skills', '.system', 'system-skill')
|
const systemSkillDir = join(root, '.codex', 'skills', '.system', 'system-skill')
|
||||||
|
|
||||||
@@ -208,7 +227,7 @@ describe('skills controller', () => {
|
|||||||
await mkdir(systemSkillDir, { recursive: true })
|
await mkdir(systemSkillDir, { recursive: true })
|
||||||
await writeFile(join(userSkillDir, 'SKILL.md'), '# User Skill\nuser codex skill\n', 'utf-8')
|
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')
|
await writeFile(join(systemSkillDir, 'SKILL.md'), '# System Skill\nsystem codex skill\n', 'utf-8')
|
||||||
process.env.HOME = root
|
const restoreHome = useHomeDir(root)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { list } = await loadController()
|
const { list } = await loadController()
|
||||||
@@ -227,21 +246,19 @@ describe('skills controller', () => {
|
|||||||
})
|
})
|
||||||
expect(mockReadConfigYamlForProfile).not.toHaveBeenCalled()
|
expect(mockReadConfigYamlForProfile).not.toHaveBeenCalled()
|
||||||
} finally {
|
} finally {
|
||||||
if (previousHome == null) delete process.env.HOME
|
restoreHome()
|
||||||
else process.env.HOME = previousHome
|
|
||||||
await rm(root, { recursive: true, force: true })
|
await rm(root, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reads Codex system skill details for the codex target', async () => {
|
it('reads Codex system skill details for the codex target', async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), 'hermes-ui-codex-system-skill-'))
|
const root = await mkdtemp(join(tmpdir(), 'hermes-ui-codex-system-skill-'))
|
||||||
const previousHome = process.env.HOME
|
|
||||||
const systemSkillDir = join(root, '.codex', 'skills', '.system', 'imagegen')
|
const systemSkillDir = join(root, '.codex', 'skills', '.system', 'imagegen')
|
||||||
|
|
||||||
await mkdir(join(systemSkillDir, 'references'), { recursive: true })
|
await mkdir(join(systemSkillDir, 'references'), { recursive: true })
|
||||||
await writeFile(join(systemSkillDir, 'SKILL.md'), '# Imagegen\nsystem image skill\n', 'utf-8')
|
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')
|
await writeFile(join(systemSkillDir, 'references', 'usage.md'), 'usage notes\n', 'utf-8')
|
||||||
process.env.HOME = root
|
const restoreHome = useHomeDir(root)
|
||||||
mockListFilesRecursive.mockResolvedValue([
|
mockListFilesRecursive.mockResolvedValue([
|
||||||
{ path: 'SKILL.md', isDir: false },
|
{ path: 'SKILL.md', isDir: false },
|
||||||
{ path: 'references/usage.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 }] })
|
expect(filesCtx.body).toEqual({ files: [{ path: 'references/usage.md', isDir: false }] })
|
||||||
} finally {
|
} finally {
|
||||||
if (previousHome == null) delete process.env.HOME
|
restoreHome()
|
||||||
else process.env.HOME = previousHome
|
|
||||||
await rm(root, { recursive: true, force: true })
|
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 () => {
|
it('updates Codex user skills but not Codex system skills', async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), 'hermes-ui-update-codex-skill-'))
|
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 userSkillDir = join(root, '.agents', 'skills', 'user-skill')
|
||||||
const systemSkillDir = join(root, '.codex', 'skills', '.system', 'system-skill')
|
const systemSkillDir = join(root, '.codex', 'skills', '.system', 'system-skill')
|
||||||
|
|
||||||
@@ -485,7 +500,7 @@ describe('skills controller', () => {
|
|||||||
await mkdir(systemSkillDir, { recursive: true })
|
await mkdir(systemSkillDir, { recursive: true })
|
||||||
await writeFile(join(userSkillDir, 'SKILL.md'), '# User Skill\n', 'utf-8')
|
await writeFile(join(userSkillDir, 'SKILL.md'), '# User Skill\n', 'utf-8')
|
||||||
await writeFile(join(systemSkillDir, 'SKILL.md'), '# System Skill\n', 'utf-8')
|
await writeFile(join(systemSkillDir, 'SKILL.md'), '# System Skill\n', 'utf-8')
|
||||||
process.env.HOME = root
|
const restoreHome = useHomeDir(root)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { updateSkill } = await loadController()
|
const { updateSkill } = await loadController()
|
||||||
@@ -516,8 +531,7 @@ describe('skills controller', () => {
|
|||||||
expect(systemCtx.status).toBe(404)
|
expect(systemCtx.status).toBe(404)
|
||||||
expect(systemCtx.body).toEqual({ error: 'Skill not found' })
|
expect(systemCtx.body).toEqual({ error: 'Skill not found' })
|
||||||
} finally {
|
} finally {
|
||||||
if (previousHome == null) delete process.env.HOME
|
restoreHome()
|
||||||
else process.env.HOME = previousHome
|
|
||||||
await rm(root, { recursive: true, force: true })
|
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(() => ({
|
const chatRunMock = vi.hoisted(() => ({
|
||||||
runAndWait: vi.fn(),
|
runAndWait: vi.fn(),
|
||||||
@@ -22,6 +22,14 @@ vi.mock('../../packages/server/src/db/hermes/session-store', async (importOrigin
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('workflow manager', () => {
|
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 () => {
|
it('returns a server-wide singleton instance', async () => {
|
||||||
const { WorkflowManager, getWorkflowManager } = await import('../../packages/server/src/services/workflow-manager')
|
const { WorkflowManager, getWorkflowManager } = await import('../../packages/server/src/services/workflow-manager')
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ describe('write gate service', () => {
|
|||||||
expect(isWriteGateSupported()).toBe(true)
|
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')
|
const fakePython = join(hermesHome, 'python')
|
||||||
await writeFile(fakePython, [
|
await writeFile(fakePython, [
|
||||||
'#!/bin/sh',
|
'#!/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 vue from '@vitejs/plugin-vue'
|
||||||
import { resolve } from 'path'
|
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({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [stripShebang(), vue()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, 'packages/client/src'),
|
'@': resolve(__dirname, 'packages/client/src'),
|
||||||
|
|||||||
Reference in New Issue
Block a user