mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix: time out automation path probes (#3797)
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
/* eslint-disable max-lines -- Why: external automation mapping and lifecycle IPC share fixtures. */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createExternalAutomation,
|
||||
listExternalAutomationManagers,
|
||||
listExternalAutomationRuns,
|
||||
runExternalAutomationAction,
|
||||
updateExternalAutomation
|
||||
} from './external-manager'
|
||||
import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers'
|
||||
import { getActiveMultiplexer } from '../ipc/ssh'
|
||||
import type { Store } from '../persistence'
|
||||
import type * as Fs from 'fs'
|
||||
|
||||
const execFileMock = vi.hoisted(() =>
|
||||
vi.fn((...args: unknown[]) => {
|
||||
@@ -16,20 +19,64 @@ const execFileMock = vi.hoisted(() =>
|
||||
const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void
|
||||
execCallback(null, '', '')
|
||||
}
|
||||
return { kill: vi.fn() }
|
||||
})
|
||||
)
|
||||
const existsSyncMock = vi.hoisted(() => vi.fn(() => false))
|
||||
|
||||
function resolveExecFileMock(...args: unknown[]) {
|
||||
const callback = args.at(-1)
|
||||
if (typeof callback === 'function') {
|
||||
const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void
|
||||
execCallback(null, '', '')
|
||||
}
|
||||
return { kill: vi.fn() }
|
||||
}
|
||||
|
||||
vi.mock('child_process', () => ({ execFile: execFileMock }))
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof Fs>('fs')
|
||||
return {
|
||||
...actual,
|
||||
existsSync: existsSyncMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../ipc/ssh', () => ({
|
||||
getActiveMultiplexer: vi.fn()
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockClear()
|
||||
execFileMock.mockReset()
|
||||
execFileMock.mockImplementation(resolveExecFileMock)
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
vi.mocked(getActiveMultiplexer).mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('listExternalAutomationManagers', () => {
|
||||
it('settles when a local command lookup hangs', async () => {
|
||||
vi.useFakeTimers()
|
||||
execFileMock.mockImplementation(() => ({ kill: vi.fn() }))
|
||||
const promise = listExternalAutomationManagers({
|
||||
getSshTargets: () => []
|
||||
} as unknown as Store)
|
||||
let settled = false
|
||||
void promise.finally(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(settled).toBe(true)
|
||||
await expect(promise).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapHermesJobs', () => {
|
||||
it('normalizes Hermes cron jobs into external automation rows', () => {
|
||||
const jobs = mapHermesJobs('hermes:local', [
|
||||
|
||||
@@ -31,6 +31,7 @@ const HERMES_CRON_DIR = join(HERMES_HOME, 'cron')
|
||||
const HERMES_JOBS_FILE = join(HERMES_CRON_DIR, 'jobs.json')
|
||||
const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json')
|
||||
const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
|
||||
const LOCAL_COMMAND_LOOKUP_TIMEOUT_MS = 5_000
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
@@ -39,13 +40,48 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
async function isCommandOnPath(command: string): Promise<boolean> {
|
||||
const finder = process.platform === 'win32' ? 'where' : 'which'
|
||||
try {
|
||||
await execFileAsync(finder, [command], { encoding: 'utf-8' })
|
||||
await runLocalCommandLookup(finder, [command])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function runLocalCommandLookup(command: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child: ReturnType<typeof execFile> | null = null
|
||||
let settled = false
|
||||
|
||||
const finish = (error: Error | null): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
|
||||
// Why: these probes run while loading Automations; a wedged PATH shim must
|
||||
// not keep the list IPC pending forever.
|
||||
const timeout = setTimeout(() => {
|
||||
child?.kill()
|
||||
finish(new Error(`Command lookup timed out after ${LOCAL_COMMAND_LOOKUP_TIMEOUT_MS}ms.`))
|
||||
}, LOCAL_COMMAND_LOOKUP_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
child = execFile(command, args, { encoding: 'utf-8' }, (error) => {
|
||||
finish(error ?? null)
|
||||
})
|
||||
} catch (error) {
|
||||
finish(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function readLocalHermesJobs(): Promise<unknown[]> {
|
||||
if (!existsSync(HERMES_JOBS_FILE)) {
|
||||
return []
|
||||
|
||||
Reference in New Issue
Block a user