mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
529 lines
19 KiB
TypeScript
529 lines
19 KiB
TypeScript
/**
|
|
* Integration test for the host CLI executor's Codex semantic `engine` and its
|
|
* alignment with the native daemon's `CommandRouter` command contract.
|
|
*
|
|
* We mock `./helperBridge.js` so every `callHelper(<cmd>, payload)` the engine
|
|
* makes is captured, then drive the engine through BOTH:
|
|
* - the engine methods directly (payload-key alignment with CommandRouter), and
|
|
* - the real MCP dispatch (`handleToolCall`) so the tool face ↔ engine ↔
|
|
* daemon-payload round-trip is exercised end-to-end.
|
|
*
|
|
* These guard the integration boundary the blueprint cares about (§4–§7): the
|
|
* ten semantic tools must reach the daemon with the exact payload keys
|
|
* CommandRouter decodes (pid/bundleId/app, index, click_count, button, value,
|
|
* action, direction, pages, from/to, key, text), and `list_apps` must render the
|
|
* daemon's structured `AppRef[]` into the text block the tool face surfaces.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
|
|
// Capture every callHelper invocation. The factory's `calls` array is closed
|
|
// over by the mock and reset per test via the exported handle.
|
|
const calls: Array<{ command: string; payload: Record<string, unknown> }> = []
|
|
let nextResult: unknown = true
|
|
let nextResolvedTarget: unknown = {
|
|
pid: 1106,
|
|
bundleId: 'com.apple.finder',
|
|
displayName: 'Finder',
|
|
path: '/System/Library/CoreServices/Finder.app',
|
|
executablePath: '/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder',
|
|
launchTime: 2106,
|
|
}
|
|
|
|
mock.module('./helperBridge.js', () => ({
|
|
callHelper: async (command: string, payload: Record<string, unknown> = {}) => {
|
|
calls.push({ command, payload })
|
|
return command === 'resolve_app_target' ? nextResolvedTarget : nextResult
|
|
},
|
|
__resetHelperBridgeState: () => {},
|
|
}))
|
|
|
|
// The host adapter pulls in heavy provider chains via analytics; the engine
|
|
// itself only needs the executor + handleToolCall, so import those directly.
|
|
async function loadExecutor() {
|
|
const { createCliExecutor } = await import('./executor.js')
|
|
return createCliExecutor({
|
|
getMouseAnimationEnabled: () => false,
|
|
getHideBeforeActionEnabled: () => false,
|
|
})
|
|
}
|
|
|
|
function lastCall() {
|
|
return calls[calls.length - 1]
|
|
}
|
|
|
|
describe('CLI executor Codex engine — daemon payload alignment', () => {
|
|
beforeEach(() => {
|
|
calls.length = 0
|
|
nextResult = true
|
|
nextResolvedTarget = {
|
|
pid: 1106,
|
|
bundleId: 'com.apple.finder',
|
|
displayName: 'Finder',
|
|
path: '/System/Library/CoreServices/Finder.app',
|
|
executablePath: '/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder',
|
|
launchTime: 2106,
|
|
}
|
|
})
|
|
|
|
test('engine is present on darwin', async () => {
|
|
if (process.platform !== 'darwin' && process.platform !== 'win32') {
|
|
// createCliExecutor intentionally rejects unsupported hosts. The exported
|
|
// pure engine-factory contract below still runs on every CI platform.
|
|
return
|
|
}
|
|
const exec = await loadExecutor()
|
|
// This suite only runs meaningfully on macOS (the native AX path). Skip the
|
|
// assertion off-darwin rather than failing CI on a non-mac runner.
|
|
if (process.platform === 'darwin') {
|
|
expect(exec.engine).toBeDefined()
|
|
} else {
|
|
expect(exec.engine).toBeUndefined()
|
|
}
|
|
})
|
|
|
|
// The remaining cases require the engine; guard once.
|
|
const itEngine = process.platform === 'darwin' ? test : test.skip
|
|
|
|
test('resolveTarget maps aliases, paths, and pids to resolve_app_target without launching', async () => {
|
|
const { createCodexEngine } = await import('./executor.js')
|
|
const engine = createCodexEngine()
|
|
for (const target of [
|
|
{ app: 'Finder' },
|
|
{ app: '/System/Applications/TextEdit.app' },
|
|
{ pid: 812 },
|
|
]) {
|
|
nextResolvedTarget = {
|
|
pid: target.pid,
|
|
bundleId: 'com.test.target',
|
|
displayName: 'Target',
|
|
path: target.app,
|
|
}
|
|
const resolved = await engine.resolveTarget(target)
|
|
expect(lastCall()).toEqual({
|
|
command: 'resolve_app_target',
|
|
payload: target,
|
|
})
|
|
expect(resolved).toEqual(nextResolvedTarget)
|
|
}
|
|
})
|
|
|
|
itEngine('getAppState sends {app} target and returns the daemon result', async () => {
|
|
const exec = await loadExecutor()
|
|
const daemonResult = {
|
|
pid: 1106,
|
|
appName: 'Finder',
|
|
bundleId: 'com.apple.finder',
|
|
windowTitle: 'Downloads',
|
|
elementCount: 5,
|
|
truncated: false,
|
|
durationMs: 12,
|
|
axText: 'App=com.apple.finder (pid 1106)\nWindow: "Downloads", App: Finder.',
|
|
screenshot: { base64: 'AAAA', width: 100, height: 80 },
|
|
}
|
|
nextResult = daemonResult
|
|
const out = await exec.engine!.getAppState({ app: 'Finder' })
|
|
expect(lastCall()).toEqual({ command: 'get_app_state', payload: { app: 'Finder' } })
|
|
expect(out).toEqual(daemonResult)
|
|
})
|
|
|
|
itEngine('getAppState maps disableDiff to the daemon payload', async () => {
|
|
const exec = await loadExecutor()
|
|
nextResult = { pid: 1, elementCount: 0, truncated: false, durationMs: 0, axText: '' }
|
|
await exec.engine!.getAppState({ app: 'Finder' }, { disableDiff: true })
|
|
expect(lastCall()).toEqual({
|
|
command: 'get_app_state',
|
|
payload: { app: 'Finder', disableDiff: true },
|
|
})
|
|
})
|
|
|
|
itEngine('getAppState target precedence: pid wins over bundleId/app', async () => {
|
|
const exec = await loadExecutor()
|
|
nextResult = { pid: 1, elementCount: 0, truncated: false, durationMs: 0, axText: '' }
|
|
await exec.engine!.getAppState({ pid: 42, bundleId: 'x', app: 'y' })
|
|
expect(lastCall().payload).toEqual({ pid: 42 })
|
|
})
|
|
|
|
itEngine('forwards the resolved process lifetime with canonical PID commands', async () => {
|
|
const exec = await loadExecutor()
|
|
const identity = {
|
|
pid: 4321,
|
|
bundleId: 'com.apple.TextEdit',
|
|
executablePath: '/System/Applications/TextEdit.app/Contents/MacOS/TextEdit',
|
|
launchTime: 12345.5,
|
|
}
|
|
nextResult = { pid: 4321, elementCount: 0, truncated: false, durationMs: 0, axText: '' }
|
|
await exec.engine!.getAppState({
|
|
pid: 4321,
|
|
expectedProcessIdentity: identity,
|
|
})
|
|
expect(lastCall()).toMatchObject({
|
|
command: 'get_app_state',
|
|
payload: { pid: 4321, expectedProcessIdentity: identity },
|
|
})
|
|
|
|
await exec.engine!.pressKey({
|
|
target: { pid: 4321, expectedProcessIdentity: identity },
|
|
key: 'super+s',
|
|
systemKeyCombos: false,
|
|
})
|
|
expect(lastCall()).toMatchObject({
|
|
command: 'press_key',
|
|
payload: { pid: 4321, expectedProcessIdentity: identity },
|
|
})
|
|
})
|
|
|
|
itEngine('getAppState empty target → no keys (frontmost)', async () => {
|
|
const exec = await loadExecutor()
|
|
nextResult = { pid: 1, elementCount: 0, truncated: false, durationMs: 0, axText: '' }
|
|
await exec.engine!.getAppState({})
|
|
expect(lastCall().payload).toEqual({})
|
|
})
|
|
|
|
itEngine('click preserves the opaque snapshot handle in the daemon payload', async () => {
|
|
const exec = await loadExecutor()
|
|
await exec.engine!.click({
|
|
target: { bundleId: 'com.apple.finder' },
|
|
index: 'g17:4',
|
|
clickCount: 2,
|
|
button: 'right',
|
|
})
|
|
expect(lastCall().command).toBe('click')
|
|
expect(lastCall().payload).toMatchObject({
|
|
bundleId: 'com.apple.finder',
|
|
index: 'g17:4',
|
|
click_count: 2,
|
|
button: 'right',
|
|
})
|
|
})
|
|
|
|
itEngine('click by coordinate sends x/y (no index)', async () => {
|
|
const exec = await loadExecutor()
|
|
await exec.engine!.click({ target: {}, x: 10, y: 20 })
|
|
expect(lastCall().payload).toMatchObject({ x: 10, y: 20 })
|
|
expect(lastCall().payload.index).toBeUndefined()
|
|
})
|
|
|
|
itEngine('setValue sends handle + value and returns {before,after}', async () => {
|
|
const exec = await loadExecutor()
|
|
nextResult = { before: 'old', after: 'new' }
|
|
const out = await exec.engine!.setValue({ target: { app: 'TextEdit' }, index: 'g18:7', value: 'new' })
|
|
expect(lastCall()).toEqual({
|
|
command: 'set_value',
|
|
payload: { app: 'TextEdit', index: 'g18:7', value: 'new' },
|
|
})
|
|
expect(out).toEqual({ before: 'old', after: 'new' })
|
|
})
|
|
|
|
itEngine('performSecondaryAction sends index + action (pretty name)', async () => {
|
|
const exec = await loadExecutor()
|
|
await exec.engine!.performSecondaryAction({ target: {}, index: 'g19:0', action: 'Raise' })
|
|
expect(lastCall()).toEqual({
|
|
command: 'perform_secondary_action',
|
|
payload: { index: 'g19:0', action: 'Raise' },
|
|
})
|
|
})
|
|
|
|
itEngine('scroll sends direction + pages + optional index', async () => {
|
|
const exec = await loadExecutor()
|
|
await exec.engine!.scroll({ target: {}, index: 'g20:2', direction: 'down', pages: 3 })
|
|
expect(lastCall().command).toBe('scroll')
|
|
expect(lastCall().payload).toMatchObject({ index: 'g20:2', direction: 'down', pages: 3 })
|
|
})
|
|
|
|
itEngine('drag sends nested from/to + button', async () => {
|
|
const exec = await loadExecutor()
|
|
await exec.engine!.drag({
|
|
target: {},
|
|
from: { x: 1, y: 2 },
|
|
to: { x: 3, y: 4 },
|
|
button: 'left',
|
|
})
|
|
expect(lastCall()).toEqual({
|
|
command: 'drag',
|
|
payload: { from: { x: 1, y: 2 }, to: { x: 3, y: 4 }, button: 'left' },
|
|
})
|
|
})
|
|
|
|
test('pressKey sends the explicit system-shortcut grant bit without a platform-gated executor', async () => {
|
|
const { createCodexEngine } = await import('./executor.js')
|
|
const engine = createCodexEngine()
|
|
await engine.pressKey({
|
|
target: { app: 'Finder' },
|
|
key: 'super+c',
|
|
systemKeyCombos: false,
|
|
})
|
|
expect(lastCall()).toEqual({
|
|
command: 'press_key',
|
|
payload: { app: 'Finder', key: 'super+c', systemKeyCombos: false },
|
|
})
|
|
})
|
|
|
|
itEngine('typeText sends text', async () => {
|
|
const exec = await loadExecutor()
|
|
await exec.engine!.typeText({ target: {}, text: 'hello' })
|
|
expect(lastCall()).toEqual({ command: 'type_text', payload: { text: 'hello' } })
|
|
})
|
|
|
|
itEngine('paste sends explicit content format', async () => {
|
|
const exec = await loadExecutor()
|
|
await exec.engine!.paste({ target: { app: 'NeteaseMusic' }, text: '喜欢你', format: 'text' })
|
|
expect(lastCall()).toEqual({
|
|
command: 'paste',
|
|
payload: { app: 'NeteaseMusic', text: '喜欢你', format: 'text' },
|
|
})
|
|
})
|
|
|
|
itEngine('selectText sends text range parameters', async () => {
|
|
const exec = await loadExecutor()
|
|
await exec.engine!.selectText({
|
|
target: { app: 'TextEdit' },
|
|
index: 'g21:4',
|
|
text: 'brown',
|
|
prefix: 'quick ',
|
|
suffix: ' fox',
|
|
selection: 'text',
|
|
})
|
|
expect(lastCall()).toEqual({
|
|
command: 'select_text',
|
|
payload: {
|
|
app: 'TextEdit',
|
|
index: 'g21:4',
|
|
text: 'brown',
|
|
prefix: 'quick ',
|
|
suffix: ' fox',
|
|
selection: 'text',
|
|
},
|
|
})
|
|
})
|
|
|
|
itEngine('listApps formats the daemon AppRef[] into the text block', async () => {
|
|
const exec = await loadExecutor()
|
|
nextResult = [
|
|
{ bundleId: 'com.apple.finder', displayName: 'Finder' },
|
|
{ bundleId: 'com.apple.Safari', displayName: 'Safari' },
|
|
]
|
|
const text = await exec.engine!.listApps()
|
|
expect(lastCall().command).toBe('list_apps')
|
|
expect(text).toBe('Finder — com.apple.finder\nSafari — com.apple.Safari')
|
|
})
|
|
|
|
itEngine('listApps on an empty daemon list returns a friendly message', async () => {
|
|
const exec = await loadExecutor()
|
|
nextResult = []
|
|
const text = await exec.engine!.listApps()
|
|
expect(text).toBe('No running applications are available to control.')
|
|
})
|
|
})
|
|
|
|
describe('handleToolCall ↔ engine end-to-end (tool face → daemon payload)', () => {
|
|
beforeEach(() => {
|
|
calls.length = 0
|
|
nextResult = true
|
|
nextResolvedTarget = {
|
|
pid: 1106,
|
|
bundleId: 'com.apple.finder',
|
|
displayName: 'Finder',
|
|
path: '/System/Library/CoreServices/Finder.app',
|
|
executablePath: '/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder',
|
|
launchTime: 2106,
|
|
}
|
|
})
|
|
afterEach(() => {
|
|
calls.length = 0
|
|
})
|
|
|
|
const itEngine = process.platform === 'darwin' ? test : test.skip
|
|
|
|
async function makeAdapter() {
|
|
const exec = await loadExecutor()
|
|
return {
|
|
serverName: 'computer-use',
|
|
logger: {
|
|
silly() {},
|
|
debug() {},
|
|
info() {},
|
|
warn() {},
|
|
error() {},
|
|
},
|
|
executor: exec,
|
|
ensureOsPermissions: async () => ({ granted: true as const }),
|
|
isDisabled: () => false,
|
|
getSubGates: () => ({}) as never,
|
|
getAutoUnhideEnabled: () => true,
|
|
cropRawPatch: () => null,
|
|
} as unknown as import('../../vendor/computer-use-mcp/types.js').ComputerUseHostAdapter
|
|
}
|
|
|
|
function authorizedOverrides(
|
|
extra: Record<string, unknown> = {},
|
|
): import('../../vendor/computer-use-mcp/types.js').ComputerUseOverrides {
|
|
return {
|
|
allowedApps: [{
|
|
bundleId: 'com.apple.finder',
|
|
displayName: 'Finder',
|
|
grantedAt: 1,
|
|
}],
|
|
grantFlags: {
|
|
clipboardRead: false,
|
|
clipboardWrite: false,
|
|
systemKeyCombos: false,
|
|
},
|
|
coordinateMode: 'pixels',
|
|
userDeniedBundleIds: [],
|
|
...extra,
|
|
} as import('../../vendor/computer-use-mcp/types.js').ComputerUseOverrides
|
|
}
|
|
|
|
itEngine('get_app_state through dispatch frames the envelope + image block', async () => {
|
|
const { handleToolCall } = await import('../../vendor/computer-use-mcp/index.js')
|
|
const adapter = await makeAdapter()
|
|
nextResult = {
|
|
pid: 1106,
|
|
appName: 'Finder',
|
|
bundleId: 'com.apple.finder',
|
|
elementCount: 1,
|
|
truncated: false,
|
|
durationMs: 3,
|
|
axText: 'App=com.apple.finder (pid 1106)\nWindow: "x", App: Finder.\n\t0 standard window x',
|
|
screenshot: { base64: 'PNGB64', width: 10, height: 10 },
|
|
}
|
|
const res = await handleToolCall(
|
|
adapter,
|
|
'get_app_state',
|
|
{ app: 'Finder' },
|
|
authorizedOverrides(),
|
|
)
|
|
expect(res.isError).toBeFalsy()
|
|
// Engine reached the daemon with the resolved {app} payload.
|
|
expect(calls.some(c => c.command === 'get_app_state')).toBe(true)
|
|
// Text block carries the version banner + <app_state> wrapping the axText.
|
|
const textBlock = res.content.find(b => b.type === 'text') as { text: string } | undefined
|
|
expect(textBlock?.text).toContain('Computer Use state (CUA App Version:')
|
|
expect(textBlock?.text).toContain('<app_state>')
|
|
expect(textBlock?.text).toContain('App=com.apple.finder (pid 1106)')
|
|
// Screenshot becomes a second image content block.
|
|
const imageBlock = res.content.find(b => b.type === 'image') as
|
|
| { data: string; mimeType: string }
|
|
| undefined
|
|
expect(imageBlock?.data).toBe('PNGB64')
|
|
expect(imageBlock?.mimeType).toBe('image/png')
|
|
})
|
|
|
|
itEngine('click through dispatch hits the daemon once and returns a refresh receipt', async () => {
|
|
const { handleToolCall } = await import('../../vendor/computer-use-mcp/index.js')
|
|
const adapter = await makeAdapter()
|
|
nextResult = true
|
|
const res = await handleToolCall(
|
|
adapter,
|
|
'click',
|
|
{ app: 'Finder', element_index: 'g17:4', mouse_button: 'left' },
|
|
authorizedOverrides(),
|
|
)
|
|
expect(res.isError).toBeFalsy()
|
|
expect(calls.map(c => c.command)).toEqual(['resolve_app_target', 'click'])
|
|
expect(calls[1].payload).toMatchObject({ pid: 1106, index: 'g17:4', button: 'left' })
|
|
const textBlock = res.content.find(b => b.type === 'text') as { text: string } | undefined
|
|
expect(textBlock?.text).toBe(
|
|
'Action completed. Call `get_app_state` to fetch the updated UI state.',
|
|
)
|
|
expect(res.content.some(block => block.type === 'image')).toBe(false)
|
|
})
|
|
|
|
itEngine('ordinary press_key sends systemKeyCombos=false through dispatch', async () => {
|
|
const { handleToolCall } = await import('../../vendor/computer-use-mcp/index.js')
|
|
const adapter = await makeAdapter()
|
|
const res = await handleToolCall(
|
|
adapter,
|
|
'press_key',
|
|
{ app: 'Finder', key: 'super+c' },
|
|
authorizedOverrides({
|
|
grantFlags: {
|
|
clipboardRead: false,
|
|
clipboardWrite: false,
|
|
systemKeyCombos: false,
|
|
},
|
|
}),
|
|
)
|
|
|
|
expect(res.isError).toBeFalsy()
|
|
expect(lastCall()).toMatchObject({
|
|
command: 'press_key',
|
|
payload: { pid: 1106, key: 'super+c', systemKeyCombos: false },
|
|
})
|
|
})
|
|
|
|
itEngine('granted dangerous press_key sends systemKeyCombos=true through dispatch', async () => {
|
|
const { handleToolCall } = await import('../../vendor/computer-use-mcp/index.js')
|
|
const adapter = await makeAdapter()
|
|
const res = await handleToolCall(
|
|
adapter,
|
|
'press_key',
|
|
{ app: 'Finder', key: 'cmd+q' },
|
|
authorizedOverrides({
|
|
grantFlags: {
|
|
clipboardRead: false,
|
|
clipboardWrite: false,
|
|
systemKeyCombos: true,
|
|
},
|
|
}),
|
|
)
|
|
|
|
expect(res.isError).toBeFalsy()
|
|
expect(lastCall()).toMatchObject({
|
|
command: 'press_key',
|
|
payload: { pid: 1106, key: 'cmd+q', systemKeyCombos: true },
|
|
})
|
|
})
|
|
|
|
itEngine('list_apps through dispatch returns the formatted text', async () => {
|
|
const { handleToolCall } = await import('../../vendor/computer-use-mcp/index.js')
|
|
const adapter = await makeAdapter()
|
|
nextResult = [{ bundleId: 'com.apple.finder', displayName: 'Finder' }]
|
|
const res = await handleToolCall(adapter, 'list_apps', {}, {})
|
|
expect(res.isError).toBeFalsy()
|
|
const textBlock = res.content.find(b => b.type === 'text') as { text: string } | undefined
|
|
expect(textBlock?.text).toBe('Finder — com.apple.finder')
|
|
})
|
|
|
|
itEngine('a daemon staleness error surfaces verbatim as a tool error', async () => {
|
|
const { handleToolCall } = await import('../../vendor/computer-use-mcp/index.js')
|
|
const adapter = await makeAdapter()
|
|
// Make the engine throw the daemon's §6 staleness message on the click.
|
|
mock.module('./helperBridge.js', () => ({
|
|
callHelper: async (command: string) => {
|
|
if (command === 'resolve_app_target') {
|
|
return nextResolvedTarget
|
|
}
|
|
throw new Error(
|
|
"The user changed 'Finder'. Re-query the latest state with get_app_state before sending more actions.",
|
|
)
|
|
},
|
|
__resetHelperBridgeState: () => {},
|
|
}))
|
|
const exec = await import('./executor.js').then(m =>
|
|
m.createCliExecutor({
|
|
getMouseAnimationEnabled: () => false,
|
|
getHideBeforeActionEnabled: () => false,
|
|
}),
|
|
)
|
|
;(adapter as { executor: unknown }).executor = exec
|
|
const res = await handleToolCall(
|
|
adapter,
|
|
'click',
|
|
{ app: 'Finder', element_index: 'g17:4' },
|
|
authorizedOverrides(),
|
|
)
|
|
expect(res.isError).toBe(true)
|
|
const textBlock = res.content.find(b => b.type === 'text') as { text: string } | undefined
|
|
expect(textBlock?.text).toContain("The user changed 'Finder'")
|
|
// Restore the capturing mock for any later tests.
|
|
mock.module('./helperBridge.js', () => ({
|
|
callHelper: async (command: string, payload: Record<string, unknown> = {}) => {
|
|
calls.push({ command, payload })
|
|
return command === 'resolve_app_target' ? nextResolvedTarget : nextResult
|
|
},
|
|
__resetHelperBridgeState: () => {},
|
|
}))
|
|
})
|
|
})
|