import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { bootstrapLaunchAgent, isMainModule, launchAgentPlist } from '../bin/hermes-yaoyao.mjs'
describe('npm-installed CLI commands', () => {
const label = 'com.samien.hermes-yaoyao'
const domain = `gui/${process.getuid?.() ?? 501}`
const entry = join(process.cwd(), 'bin', 'hermes-yaoyao.mjs')
const { version } = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8'))
let root: string
let plist: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'yaoyao-npm-cli-'))
const bin = join(root, 'bin')
mkdirSync(bin)
const launchAgents = join(root, 'Library', 'LaunchAgents')
mkdirSync(launchAgents, { recursive: true })
plist = join(launchAgents, `${label}.plist`)
symlinkSync(entry, join(bin, 'yaoyao'))
writeFileSync(join(bin, 'launchctl'), `#!${process.execPath}
import { appendFileSync } from 'node:fs'
const args = process.argv.slice(2)
appendFileSync(process.env.YAOYAO_CLI_TEST_LOG, JSON.stringify(args) + '\\n')
if (args[0] === process.env.YAOYAO_CLI_TEST_FAIL) {
process.stderr.write('fixture launchctl failure\\n')
process.exit(1)
}
if (args[0] === 'print' && process.env.YAOYAO_CLI_TEST_LOADED !== '1') process.exit(1)
`, { mode: 0o755 })
})
afterEach(() => { rmSync(root, { recursive: true, force: true }) })
function runCli(args: string[], env: NodeJS.ProcessEnv = {}) {
return spawnSync(process.execPath, [join(root, 'bin', 'yaoyao'), ...args], {
cwd: root,
encoding: 'utf8',
timeout: 5_000,
env: {
...process.env,
HOME: root,
PATH: `${join(root, 'bin')}:${process.env.PATH}`,
YAOYAO_HOME: join(root, 'data'),
HERMES_YAOYAO_SERVICE_ROOT: join(root, 'other-runtime'),
YAOYAO_CLI_TEST_LOG: join(root, 'launchctl.log'),
...env,
},
})
}
function calls(): string[][] {
const log = join(root, 'launchctl.log')
return existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').map(line => JSON.parse(line)) : []
}
it.each(['version', '--version', '-v'])('queries the package version through yaoyao: %s', command => {
const result = runCli([command])
expect(result.status).toBe(0)
expect(result.stdout).toBe(`${version}\n`)
expect(result.stderr).toBe('')
expect(calls()).toEqual([])
expect(existsSync(join(root, 'data'))).toBe(false)
})
it('restarts the installed service while preserving its configuration and data', () => {
writeFileSync(plist, 'saved service configuration')
mkdirSync(join(root, 'data'))
const data = join(root, 'data', 'keep.txt')
writeFileSync(data, 'saved user data')
const result = runCli(['service', 'restart'], { YAOYAO_CLI_TEST_LOADED: '1' })
expect(result.status).toBe(0)
expect(result.stdout).toBe(`已重启 ${label}\n`)
expect(calls()).toEqual([
['print', `${domain}/${label}`],
['bootout', `${domain}/${label}`],
['bootstrap', domain, plist],
])
expect(readFileSync(plist, 'utf8')).toBe('saved service configuration')
expect(readFileSync(data, 'utf8')).toBe('saved user data')
})
it('starts an installed service that was stopped', () => {
writeFileSync(plist, 'saved service configuration')
const result = runCli(['service', 'restart'])
expect(result.status).toBe(0)
expect(calls()).toEqual([['print', `${domain}/${label}`], ['bootstrap', domain, plist]])
})
it('explains how to install a missing service without invoking launchctl', () => {
const result = runCli(['service', 'restart'])
expect(result.status).toBe(1)
expect(result.stderr).toContain('请先运行 yaoyao service install')
expect(result.stdout).toBe('')
expect(calls()).toEqual([])
})
it.each(['bootout', 'bootstrap'])('reports a failed %s without reporting restart success', command => {
writeFileSync(plist, 'saved service configuration')
const result = runCli(['service', 'restart'], {
YAOYAO_CLI_TEST_LOADED: '1',
YAOYAO_CLI_TEST_FAIL: command,
})
expect(result.status).toBe(1)
expect(result.stdout).toBe('')
expect(result.stderr).toContain('fixture launchctl failure')
expect(calls().at(-1)?.[0]).toBe(command)
})
})
describe('hermes-yaoyao LaunchAgent plist', () => {
it('includes the local Hermes CLI directory in PATH', () => {
const plist = launchAgentPlist()
expect(plist).toContain(`${join(homedir(), '.local', 'bin')}:`)
})
it('enables Dashboard supervision by default', () => {
expect(launchAgentPlist()).toContain('HERMES_YAOYAO_SUPERVISE_DASHBOARD')
})
it('uses 15300 without injecting credentials into 9119 startup', () => {
const plist = launchAgentPlist({ environment: {} })
expect(plist).toContain('HERMES_YAOYAO_PORT\n 15300')
expect(plist).not.toContain('HERMES_YAOYAO_UPSTREAM_USERNAME')
expect(plist).not.toContain('HERMES_YAOYAO_UPSTREAM_PASSWORD')
})
it('binds managed Web to the trusted LAN and enables local Dashboard supervision by default', () => {
const plist = launchAgentPlist({ environment: {} })
expect(plist).toContain('HERMES_YAOYAO_HOST\n 0.0.0.0')
expect(plist).toContain('HERMES_YAOYAO_ALLOW_INSECURE_LAN\n 1')
expect(plist).toContain('HERMES_YAOYAO_SUPERVISE_DASHBOARD\n 1')
})
it('keeps the managed installation on LAN even with stale loopback environment', () => {
const plist = launchAgentPlist({
environment: {
HERMES_YAOYAO_HOST: '127.0.0.1',
HERMES_YAOYAO_ALLOW_INSECURE_LAN: '0',
},
})
expect(plist).toContain('HERMES_YAOYAO_HOST\n 0.0.0.0')
expect(plist).toContain('HERMES_YAOYAO_ALLOW_INSECURE_LAN\n 1')
})
it('preserves APNs provider settings in the LaunchAgent', () => {
const plist = launchAgentPlist({
environment: {
HERMES_YAOYAO_APNS_KEY_FILE: '/Users/test/AuthKey_ABC123.p8',
HERMES_YAOYAO_APNS_KEY_ID: 'ABC123',
HERMES_YAOYAO_APNS_TEAM_ID: 'TEAM123',
HERMES_YAOYAO_APNS_TOPIC: 'cn.samien.yaoyao.hermes',
},
})
expect(plist).toContain('HERMES_YAOYAO_APNS_KEY_FILE')
expect(plist).toContain('/Users/test/AuthKey_ABC123.p8')
expect(plist).toContain('HERMES_YAOYAO_APNS_KEY_ID')
expect(plist).toContain('HERMES_YAOYAO_APNS_TEAM_ID')
expect(plist).toContain('HERMES_YAOYAO_APNS_TOPIC')
})
it('preserves FCM provider settings in the LaunchAgent', () => {
const plist = launchAgentPlist({
environment: {
HERMES_YAOYAO_FCM_SERVICE_ACCOUNT_FILE: '/Users/test/firebase-service-account.json',
HERMES_YAOYAO_FCM_PROJECT_ID: 'yaoyao-test-project',
HERMES_YAOYAO_FCM_PACKAGE_NAME: 'cn.samien.yaoyao.hermes',
},
})
expect(plist).toContain('HERMES_YAOYAO_FCM_SERVICE_ACCOUNT_FILE')
expect(plist).toContain('/Users/test/firebase-service-account.json')
expect(plist).toContain('HERMES_YAOYAO_FCM_PROJECT_ID')
expect(plist).toContain('yaoyao-test-project')
expect(plist).toContain('HERMES_YAOYAO_FCM_PACKAGE_NAME')
expect(plist).toContain('cn.samien.yaoyao.hermes')
})
it('can point the managed service at the stable current release link', () => {
const root = join(homedir(), '.local', 'share', 'hermes-yaoyao', 'current')
const plist = launchAgentPlist({ serviceRoot: root })
expect(plist).toContain(`${join(root, 'dist-server', 'server', 'index.js')}`)
expect(plist).toContain(`WorkingDirectory${root}`)
})
it('runs the CLI when its entry path uses the stable current symlink', () => {
const root = mkdtempSync(join(tmpdir(), 'hermes-service-cli-'))
const entry = join(root, 'hermes-yaoyao.mjs')
symlinkSync(join(process.cwd(), 'bin', 'hermes-yaoyao.mjs'), entry)
try {
expect(isMainModule(entry)).toBe(true)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('retries the transient launchctl bootstrap race after bootout', async () => {
const error = Object.assign(new Error('launchctl bootstrap failed'), {
stderr: 'Bootstrap failed: 5: Input/output error',
})
const run = vi.fn()
.mockImplementationOnce(() => { throw error })
.mockReturnValue('')
const wait = vi.fn(async () => {})
await expect(bootstrapLaunchAgent(run, wait)).resolves.toBe('')
expect(run).toHaveBeenCalledTimes(2)
expect(wait).toHaveBeenCalledWith(250)
})
it('keeps retrying until a slow launchd removal finishes', async () => {
const error = Object.assign(new Error('launchctl bootstrap failed'), {
stderr: 'Bootstrap failed: 5: Input/output error',
})
let elapsedMs = 0
const run = vi.fn(() => {
if (elapsedMs < 5_000) throw error
return ''
})
const wait = vi.fn(async (waitMs: number) => { elapsedMs += waitMs })
await expect(bootstrapLaunchAgent(run, wait, {
timeoutMs: 20_000,
now: () => elapsedMs,
})).resolves.toBe('')
expect(elapsedMs).toBeGreaterThanOrEqual(5_000)
expect(run).toHaveBeenCalledTimes(6)
expect(wait.mock.calls.map(([waitMs]) => waitMs)).toEqual([250, 500, 1_000, 2_000, 2_000])
})
it('stops retrying a transient launchctl error at the deadline', async () => {
const error = Object.assign(new Error('launchctl bootstrap failed'), {
stderr: 'Bootstrap failed: 5: Input/output error',
})
let elapsedMs = 0
const run = vi.fn(() => { throw error })
const wait = vi.fn(async (waitMs: number) => { elapsedMs += waitMs })
await expect(bootstrapLaunchAgent(run, wait, {
timeoutMs: 2_000,
now: () => elapsedMs,
})).rejects.toThrow(error)
expect(elapsedMs).toBe(2_000)
expect(run).toHaveBeenCalledTimes(5)
})
it('does not retry permanent launchctl bootstrap failures', async () => {
const error = new Error('Bootstrap failed: 125: Domain does not support specified action')
const run = vi.fn(() => { throw error })
const wait = vi.fn(async () => {})
await expect(bootstrapLaunchAgent(run, wait)).rejects.toThrow(error)
expect(run).toHaveBeenCalledTimes(1)
expect(wait).not.toHaveBeenCalled()
})
})