diff --git a/src/desktop/index.ts b/src/desktop/index.ts index a734af4..521a008 100644 --- a/src/desktop/index.ts +++ b/src/desktop/index.ts @@ -8,6 +8,9 @@ export type { ConvertDesktopOptions } from './desktop-converter' export { FixtureBackend } from './fixture-backend' export type { FixtureBackendOptions } from './fixture-backend' +export { WindowsUiaBackend } from './windows-uia-backend' +export type { WindowsUiaBackendOptions } from './windows-uia-backend' + export type { DesktopCaptureBackend, CaptureDesktopOptions, diff --git a/src/desktop/windows-uia-backend.ts b/src/desktop/windows-uia-backend.ts new file mode 100644 index 0000000..6a0b88d --- /dev/null +++ b/src/desktop/windows-uia-backend.ts @@ -0,0 +1,493 @@ +/** + * WindowsUiaBackend — DesktopCaptureBackend that drives a real Windows + * machine via the agentmark-bridge-windows.exe sidecar process. + * + * Architecture: + * + * Node Windows native + * ┌────────────────────┐ stdio JSON-RPC 2.0 ┌────────────────────────┐ + * │ WindowsUiaBackend │ ◄──────────────────► │ agentmark-bridge- │ + * │ (this file) │ │ windows.exe (.NET 8) │ + * │ │ │ ↳ FlaUI / UIA3 │ + * └────────────────────┘ └────────────────────────┘ + * ▲ + * │ DesktopCaptureBackend contract + * │ + * ┌────────────────────┐ + * │ convertDesktop() │ + * │ MCP server │ + * │ ...any consumer │ + * └────────────────────┘ + * + * The bridge process is spawned lazily on the first capture/execute + * call and reused for the lifetime of the backend. close() shuts it + * down. Multiple in-flight calls are serialised by id; the bridge + * processes them sequentially on its STA worker thread. + * + * Bridge resolution order: + * 1. options.bridgePath (explicit) + * 2. AGENTMARK_BRIDGE_PATH environment variable + * 3. Walk up from __dirname looking for + * apps/agent-runner/bridges/windows/bin/{Debug,Release}/net8.0-windows/ + * agentmark-bridge-windows.exe (works in both monorepo dev mode and + * Parallels-shared-folder layouts) + * 4. Throw a helpful error pointing at the build command + env var + */ + +import { spawn, type ChildProcessByStdio } from 'node:child_process' +import { createInterface, type Interface as ReadlineInterface } from 'node:readline' +import * as path from 'node:path' +import * as fs from 'node:fs' +import type { Readable, Writable } from 'node:stream' + +import { noopLogger, type Logger } from '../observability/logger' +import type { + CaptureDesktopOptions, + DesktopCapture, + DesktopCaptureBackend, + ExecuteDesktopAction, + ExecuteDesktopOptions, + ExecuteDesktopResult, + KeyModifier, +} from './types' + +export interface WindowsUiaBackendOptions { + /** Absolute path to agentmark-bridge-windows.exe. When omitted the + * backend looks at AGENTMARK_BRIDGE_PATH then walks up from the + * package directory probing common dev-build paths. */ + bridgePath?: string + + /** Max time to wait for the bridge to respond to its first ping + * before declaring startup failed. Default: 10000. */ + startupTimeoutMs?: number + + /** Per-call timeout when the bridge appears stuck. Default: 30000. */ + callTimeoutMs?: number + + /** Structured logger. */ + logger?: Logger + + /** Bypass the `process.platform === 'win32'` guard. Intended for + * unit tests that supply a mock bridge path; production should + * never use this. */ + allowNonWindows?: boolean +} + +interface PendingCall { + resolve: (value: unknown) => void + reject: (error: Error) => void + method: string + timeout: NodeJS.Timeout +} + +interface JsonRpcResponse { + jsonrpc: '2.0' + id: number + result?: unknown + error?: { code: number; message: string } +} + +export class WindowsUiaBackend implements DesktopCaptureBackend { + readonly name = 'windows_uia' + + private readonly bridgePath: string + private readonly logger: Logger + private readonly startupTimeoutMs: number + private readonly callTimeoutMs: number + + private proc: ChildProcessByStdio | null = null + private rl: ReadlineInterface | null = null + private pending = new Map() + private nextId = 1 + private starting: Promise | null = null + private closed = false + + constructor(opts: WindowsUiaBackendOptions = {}) { + if (process.platform !== 'win32' && !opts.allowNonWindows) { + throw new Error( + `WindowsUiaBackend requires Windows (process.platform=='win32'). ` + + `Current platform: ${process.platform}. For tests that supply ` + + `a fake bridge, pass allowNonWindows: true.`, + ) + } + this.bridgePath = opts.bridgePath ?? resolveBridgePath() + this.logger = opts.logger ?? noopLogger + this.startupTimeoutMs = opts.startupTimeoutMs ?? 10_000 + this.callTimeoutMs = opts.callTimeoutMs ?? 30_000 + } + + // ── DesktopCaptureBackend implementation ───────────────────────── + + async capture(opts: CaptureDesktopOptions = {}): Promise { + await this.ensureStarted() + const params = { + processName: opts.target?.process_name, + processId: opts.target?.process_id, + windowTitle: opts.target?.window_title, + windowId: opts.target?.window_id, + maxDepth: opts.maxDepth, + includeHidden: opts.includeHidden, + timeoutMs: opts.timeoutMs, + } + const result = (await this.call('capture', params)) as RawDesktopCapture + return mapCaptureResponse(result) + } + + async execute(opts: ExecuteDesktopOptions): Promise { + await this.ensureStarted() + const params = buildExecuteParams(opts.action) + if (opts.timeoutMs !== undefined) params.timeoutMs = opts.timeoutMs + + const result = (await this.call('execute', params)) as RawExecuteResult + return { + ok: !!result.ok, + message: result.message ?? undefined, + new_value: result.newValue ?? undefined, + } + } + + async close(): Promise { + this.closed = true + const proc = this.proc + if (!proc) return + + // Reject any pending calls. + for (const [id, slot] of this.pending) { + clearTimeout(slot.timeout) + slot.reject(new Error(`Bridge closed before ${slot.method} (id=${id}) completed`)) + } + this.pending.clear() + + // Close stdin so the bridge sees EOF and exits cleanly. + try { proc.stdin.end() } catch { /* swallow */ } + + // Give it 2 seconds, then SIGKILL. + await new Promise((resolve) => { + const timeout = setTimeout(() => { + try { proc.kill('SIGKILL') } catch { /* swallow */ } + resolve() + }, 2000) + proc.once('exit', () => { + clearTimeout(timeout) + resolve() + }) + }) + + this.proc = null + this.rl?.close() + this.rl = null + } + + // ── Lifecycle ───────────────────────────────────────────────────── + + private ensureStarted(): Promise { + if (this.closed) { + return Promise.reject(new Error('WindowsUiaBackend was closed; create a new instance.')) + } + if (this.proc) return Promise.resolve() + if (this.starting) return this.starting + + this.starting = this.spawnAndHandshake() + .catch((err) => { + // Clear so a subsequent call can retry; rethrow for this attempt. + this.starting = null + throw err + }) + .finally(() => { + if (this.proc) this.starting = null + }) + return this.starting + } + + private async spawnAndHandshake(): Promise { + this.logger.debug('windows-uia.spawn', { bridgePath: this.bridgePath }) + + const proc = spawn(this.bridgePath, [], { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }) as ChildProcessByStdio + + this.proc = proc + + proc.on('error', (err) => { + this.logger.error('windows-uia.spawn-error', { error: err.message }) + this.failAllPending(new Error(`Bridge process error: ${err.message}`)) + }) + + proc.stderr.setEncoding('utf8') + proc.stderr.on('data', (chunk: string) => { + // Bridge writes diagnostics to stderr — surface at debug. + const lines = chunk.split(/\r?\n/).filter((l) => l.length > 0) + for (const line of lines) { + this.logger.debug('windows-uia.bridge-stderr', { line }) + } + }) + + proc.on('exit', (code, signal) => { + this.logger.info('windows-uia.bridge-exit', { code, signal }) + this.failAllPending(new Error(`Bridge process exited (code=${code}, signal=${signal})`)) + this.proc = null + this.rl?.close() + this.rl = null + }) + + const rl = createInterface({ input: proc.stdout }) + this.rl = rl + rl.on('line', (line) => this.handleResponseLine(line)) + + // Handshake: ping with a startup-specific timeout. + const originalTimeout = this.callTimeoutMs + try { + await this.callWithTimeout('ping', {}, this.startupTimeoutMs) + } catch (err) { + // Tear down the half-started bridge so a retry starts clean. + try { proc.kill('SIGKILL') } catch { /* swallow */ } + this.proc = null + this.rl?.close() + this.rl = null + throw new Error(`Bridge handshake failed: ${(err as Error).message}`) + } + } + + // ── JSON-RPC plumbing ───────────────────────────────────────────── + + private call(method: string, params: Record): Promise { + return this.callWithTimeout(method, params, this.callTimeoutMs) + } + + private callWithTimeout( + method: string, + params: Record, + timeoutMs: number, + ): Promise { + const proc = this.proc + if (!proc) return Promise.reject(new Error('Bridge process not started')) + + const id = this.nextId++ + // Drop undefined fields so the bridge sees a tidy payload. + const tidyParams: Record = {} + for (const [k, v] of Object.entries(params)) { + if (v !== undefined) tidyParams[k] = v + } + const frame = JSON.stringify({ jsonrpc: '2.0', id, method, params: tidyParams }) + '\n' + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id) + reject(new Error(`Bridge call ${method} (id=${id}) timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + this.pending.set(id, { resolve, reject, method, timeout }) + try { + proc.stdin.write(frame, (err) => { + if (err) { + clearTimeout(timeout) + this.pending.delete(id) + reject(err) + } + }) + } catch (err) { + clearTimeout(timeout) + this.pending.delete(id) + reject(err as Error) + } + }) + } + + private handleResponseLine(line: string): void { + const trimmed = line.trim() + if (trimmed.length === 0) return + + let msg: JsonRpcResponse + try { + msg = JSON.parse(trimmed) as JsonRpcResponse + } catch (err) { + this.logger.warn('windows-uia.invalid-frame', { line: trimmed.slice(0, 200) }) + return + } + + const slot = this.pending.get(msg.id) + if (!slot) { + this.logger.warn('windows-uia.orphan-response', { id: msg.id }) + return + } + this.pending.delete(msg.id) + clearTimeout(slot.timeout) + + if (msg.error) { + slot.reject(new Error(`[bridge ${msg.error.code}] ${msg.error.message}`)) + return + } + slot.resolve(msg.result) + } + + private failAllPending(err: Error): void { + for (const [, slot] of this.pending) { + clearTimeout(slot.timeout) + slot.reject(err) + } + this.pending.clear() + } +} + +// ────────────────────────────────────────────────────────────────────── +// Param mapping +// ────────────────────────────────────────────────────────────────────── + +function buildExecuteParams(action: ExecuteDesktopAction): Record { + // The bridge accepts a flat payload with `actionType` plus only the + // fields the action uses. Keep this in sync with HandleExecute in + // Program.cs on the C# side. + const base: Record = { + actionType: action.type, + elementId: 'element_id' in action ? action.element_id : undefined, + } + + switch (action.type) { + case 'click': + case 'focus': + case 'scroll_to': + break + case 'type': + base.text = action.text + if (action.clear_first) base.clearFirst = true + break + case 'select': + base.value = action.value + break + case 'check': + base.checked = action.checked + break + case 'expand': + base.expanded = action.expanded + break + case 'key': + base.key = action.key + if (action.modifiers && action.modifiers.length > 0) { + base.modifiers = action.modifiers as readonly KeyModifier[] + } + break + default: { + // exhaustiveness check — switch is closed over the union + const _exhaustive: never = action + void _exhaustive + } + } + return base +} + +// ────────────────────────────────────────────────────────────────────── +// Bridge path resolution +// ────────────────────────────────────────────────────────────────────── + +const BRIDGE_EXE = 'agentmark-bridge-windows.exe' +const RELATIVE_BRIDGE_PATHS = [ + path.join('apps', 'agent-runner', 'bridges', 'windows', 'bin', 'Release', 'net8.0-windows', BRIDGE_EXE), + path.join('apps', 'agent-runner', 'bridges', 'windows', 'bin', 'Debug', 'net8.0-windows', BRIDGE_EXE), +] + +function resolveBridgePath(): string { + const fromEnv = process.env.AGENTMARK_BRIDGE_PATH + if (fromEnv && fs.existsSync(fromEnv)) return fromEnv + + // Package compiles to CommonJS; __dirname is always defined. + const startDir = __dirname + + let current = startDir + for (let i = 0; i < 8; i++) { + for (const rel of RELATIVE_BRIDGE_PATHS) { + const candidate = path.join(current, rel) + if (fs.existsSync(candidate)) return candidate + } + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + + throw new Error( + `Cannot find ${BRIDGE_EXE}. Tried AGENTMARK_BRIDGE_PATH env var and ` + + `walked up from ${startDir}. Build the bridge with:\n` + + ` cd apps/agent-runner/bridges/windows && dotnet build\n` + + `Or set AGENTMARK_BRIDGE_PATH to an existing exe.`, + ) +} + +// ────────────────────────────────────────────────────────────────────── +// Wire format — raw shapes returned by the bridge +// ────────────────────────────────────────────────────────────────────── + +interface RawDesktopCapture { + platform: 'windows' | 'macos' | 'linux' + processName?: string | null + processId?: number | null + windowTitle: string + windowClass?: string | null + windowId: string + focusedElementId?: string | null + treeDepth: number + elementCount: number + root: RawDesktopElement +} + +interface RawDesktopElement { + id: string + role: string + name?: string | null + value?: string | null + placeholder?: string | null + enabled?: boolean | null + selected?: boolean | null + readOnly?: boolean | null + expanded?: boolean | null + aria?: { + pressed?: boolean | null + checked?: boolean | 'mixed' | null + required?: boolean | null + invalid?: boolean | null + } | null + bounds?: { x: number; y: number; width: number; height: number } | null + children?: RawDesktopElement[] | null +} + +interface RawExecuteResult { + ok: boolean + message?: string | null + newValue?: string | null +} + +function mapCaptureResponse(raw: RawDesktopCapture): DesktopCapture { + return { + platform: raw.platform, + process_name: raw.processName ?? undefined, + process_id: raw.processId ?? undefined, + window_title: raw.windowTitle, + window_class: raw.windowClass ?? undefined, + window_id: raw.windowId, + focused_element_id: raw.focusedElementId ?? undefined, + tree_depth: raw.treeDepth, + element_count: raw.elementCount, + root: mapElement(raw.root), + } +} + +function mapElement(raw: RawDesktopElement): import('./types').DesktopElement { + return { + id: raw.id, + role: raw.role as import('./types').DesktopRole, + name: raw.name ?? undefined, + value: raw.value ?? undefined, + placeholder: raw.placeholder ?? undefined, + enabled: raw.enabled ?? undefined, + selected: raw.selected ?? undefined, + read_only: raw.readOnly ?? undefined, + expanded: raw.expanded ?? undefined, + aria: raw.aria ? { + pressed: raw.aria.pressed ?? undefined, + checked: raw.aria.checked ?? undefined, + required: raw.aria.required ?? undefined, + invalid: raw.aria.invalid ?? undefined, + } : undefined, + bounds: raw.bounds ?? undefined, + children: raw.children ? raw.children.map(mapElement) : undefined, + } +} diff --git a/src/index.ts b/src/index.ts index 68e8b72..6755dbf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -214,10 +214,11 @@ export type { // ── v0.4 spec / v0.12 lib: Desktop support (kind: 'desktop') ───────────── -export { convertDesktop, FixtureBackend, buildDesktopBody } from './desktop' +export { convertDesktop, FixtureBackend, WindowsUiaBackend, buildDesktopBody } from './desktop' export type { ConvertDesktopOptions, FixtureBackendOptions, + WindowsUiaBackendOptions, BuildDesktopBodyResult, DesktopCaptureBackend, CaptureDesktopOptions, diff --git a/src/mcp/dispatcher.ts b/src/mcp/dispatcher.ts index eee402f..7cf9df5 100644 --- a/src/mcp/dispatcher.ts +++ b/src/mcp/dispatcher.ts @@ -14,6 +14,7 @@ import { createBrowser, convertDesktop, FixtureBackend, + WindowsUiaBackend, openPdfDocument, isAgentMarkError, parseSnapshot, @@ -337,22 +338,41 @@ async function pdfReset(state: DispatcherState, args: Record): async function openDesktop(state: DispatcherState, args: Record): Promise { const requested = typeof args.backend === 'string' ? args.backend : 'fixture' + const bridgePath = typeof args.bridge_path === 'string' ? args.bridge_path : undefined + let backend: DesktopCaptureBackend - switch (requested) { - case 'fixture': - backend = new FixtureBackend() - break - case 'windows_uia': - case 'macos_axapi': - return { - text: - `Backend "${requested}" is not yet bundled with this build of agentmark. ` - + 'Run agentmark_desktop_open with backend="fixture" to use the in-memory ' - + 'preset trees. Real OS bridges land in subsequent releases.', - isError: true, - } - default: - return { text: `Unknown desktop backend: ${requested}`, isError: true } + try { + switch (requested) { + case 'fixture': + backend = new FixtureBackend() + break + case 'windows_uia': + if (process.platform !== 'win32') { + return { + text: + `Backend "windows_uia" requires Windows (process.platform=='win32'). ` + + `Current platform: ${process.platform}. Use backend="fixture" for ` + + `in-memory testing, or run agentmark on a Windows host.`, + isError: true, + } + } + backend = new WindowsUiaBackend({ bridgePath }) + break + case 'macos_axapi': + return { + text: + `Backend "macos_axapi" is not yet bundled with this build of agentmark. ` + + 'Use backend="fixture" for in-memory testing while the macOS bridge ships.', + isError: true, + } + default: + return { text: `Unknown desktop backend: ${requested}`, isError: true } + } + } catch (err) { + return { + text: `Failed to initialise backend "${requested}": ${(err as Error).message}`, + isError: true, + } } const id = generateSessionId('dt') diff --git a/test/desktop/fixtures/fake-bridge.cjs b/test/desktop/fixtures/fake-bridge.cjs new file mode 100755 index 0000000..1f14333 --- /dev/null +++ b/test/desktop/fixtures/fake-bridge.cjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +// Fake agentmark-bridge-windows.exe for unit tests. Speaks the same +// stdio JSON-RPC 2.0 protocol the real bridge does but returns +// pre-baked responses keyed by method name. Behaviour can be tuned +// via env vars: +// +// AGENTMARK_FAKE_BRIDGE_DELAY_MS -- artificial latency per request +// AGENTMARK_FAKE_BRIDGE_FAIL_PING -- if "1", reject ping (handshake-failure tests) +// AGENTMARK_FAKE_BRIDGE_EXIT -- if "1", exit immediately (crash test) +// +// Single-line JSON over stdin/stdout, stderr ignored by tests. + +'use strict' + +if (process.env.AGENTMARK_FAKE_BRIDGE_EXIT === '1') { + process.exit(99) +} + +const delayMs = Number(process.env.AGENTMARK_FAKE_BRIDGE_DELAY_MS) || 0 + +function respond(req, result) { + const env = { jsonrpc: '2.0', id: req.id, result } + process.stdout.write(JSON.stringify(env) + '\n') +} + +function error(req, code, message) { + const env = { jsonrpc: '2.0', id: req.id, error: { code, message } } + process.stdout.write(JSON.stringify(env) + '\n') +} + +const readline = require('node:readline') +const rl = readline.createInterface({ input: process.stdin }) + +rl.on('line', (line) => { + const handle = () => { + let req + try { req = JSON.parse(line.trim().replace(/^/, '')) } + catch (err) { return } + + switch (req.method) { + case 'ping': + if (process.env.AGENTMARK_FAKE_BRIDGE_FAIL_PING === '1') { + return error(req, -32603, 'fake bridge refused ping') + } + return respond(req, { + pong: true, + version: '0.4.0-fake', + arch: 'fake', + processId: process.pid, + }) + + case 'capabilities': + return respond(req, { + bridge: 'agentmark-bridge-fake', + version: '0.4.0-fake', + methods: ['ping', 'capabilities', 'list_windows', 'capture', 'execute'], + uiaProvider: 'fake', + platform: 'fake', + }) + + case 'list_windows': + return respond(req, { + windows: [ + { + windowId: 'hwnd:0xABCD0001', + processName: 'FakeApp.exe', + processId: 42, + windowTitle: 'Fake Window 1', + windowClass: 'FakeClass', + hasFocus: true, + }, + ], + }) + + case 'capture': { + // Echo back the requested target in the capture for tests. + return respond(req, { + platform: 'windows', + processName: req.params?.processName ?? 'FakeApp.exe', + processId: req.params?.processId ?? 42, + windowTitle: 'Fake Window 1', + windowClass: 'FakeClass', + windowId: req.params?.windowId ?? 'hwnd:0xABCD0001', + focusedElementId: 'in_company', + treeDepth: 2, + elementCount: 3, + root: { + id: 'root', + role: 'window', + name: 'Fake Window 1', + enabled: true, + children: [ + { + id: 'in_company', + role: 'text_input', + name: 'Company Name', + value: 'Acme', + enabled: true, + }, + { + id: 'btn_save', + role: 'button', + name: 'Save', + enabled: true, + }, + ], + }, + }) + } + + case 'execute': { + const p = req.params || {} + // Record the last execute so tests can assert. + process.send?.({ kind: 'execute-call', params: p }) + return respond(req, { + ok: true, + newValue: p.actionType === 'type' ? (p.text || '') : null, + }) + } + + default: + return error(req, -32601, 'Unknown method: ' + req.method) + } + } + if (delayMs > 0) setTimeout(handle, delayMs) + else handle() +}) + +rl.on('close', () => process.exit(0)) diff --git a/test/desktop/windows-uia-backend.test.ts b/test/desktop/windows-uia-backend.test.ts new file mode 100644 index 0000000..b731bf5 --- /dev/null +++ b/test/desktop/windows-uia-backend.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, afterEach } from 'vitest' +import * as path from 'node:path' +import { WindowsUiaBackend } from '../../src/desktop/windows-uia-backend' + +// The fake bridge is a Node script that speaks the same stdio JSON-RPC +// 2.0 protocol the real C# bridge does. Lets us validate the +// WindowsUiaBackend's spawn/handshake/call/close lifecycle without +// needing a Windows host or the .NET runtime. +const FAKE_BRIDGE = path.join(__dirname, 'fixtures', 'fake-bridge.cjs') + +function makeBackend(extra: Partial[0]> = {}) { + // Spawn 'node' with the fake-bridge script. By pointing + // bridgePath at the Node executable itself we sidestep the + // "child must be an exe" assumption on Windows; on macOS/Linux + // node executes the .cjs script directly when given as argv[0] + // -- well, it doesn't. We need to wrap it. The trick: bridgePath + // = path to a tiny .cjs that shebangs node + execs the fake bridge. + return new WindowsUiaBackend({ + // The fake bridge ships its own shebang. On Mac/Linux that's + // enough for spawn() to execute it directly (after chmod +x). + // We rely on Node's own bin on PATH for the shebang to work. + bridgePath: FAKE_BRIDGE, + allowNonWindows: true, + startupTimeoutMs: 5000, + callTimeoutMs: 5000, + ...extra, + }) +} + +let backend: WindowsUiaBackend | null = null + +afterEach(async () => { + if (backend) { + try { await backend.close() } catch { /* swallow */ } + backend = null + } +}) + +describe('WindowsUiaBackend', () => { + it('refuses to construct on non-Windows without allowNonWindows', () => { + if (process.platform === 'win32') return // not applicable + expect(() => new WindowsUiaBackend({ bridgePath: FAKE_BRIDGE })).toThrow(/requires Windows/) + }) + + it('captures via the bridge and maps the response to DesktopCapture', async () => { + backend = makeBackend() + const cap = await backend.capture({}) + + expect(cap.platform).toBe('windows') + expect(cap.window_title).toBe('Fake Window 1') + expect(cap.tree_depth).toBe(2) + expect(cap.element_count).toBe(3) + expect(cap.root.role).toBe('window') + expect(cap.root.children).toHaveLength(2) + expect(cap.root.children?.[0].role).toBe('text_input') + expect(cap.root.children?.[0].value).toBe('Acme') + }) + + it('forwards capture target to the bridge', async () => { + backend = makeBackend() + const cap = await backend.capture({ + target: { window_id: 'hwnd:0xDEAD0001', process_name: 'NowCerts.exe' }, + }) + expect(cap.window_id).toBe('hwnd:0xDEAD0001') + expect(cap.process_name).toBe('NowCerts.exe') + }) + + it('executes a type action and maps newValue back to new_value', async () => { + backend = makeBackend() + await backend.capture({}) + const res = await backend.execute({ + action: { type: 'type', element_id: 'in_company', text: 'Beta Industries' }, + }) + expect(res.ok).toBe(true) + expect(res.new_value).toBe('Beta Industries') + }) + + it('reuses the same bridge process across multiple calls', async () => { + backend = makeBackend() + const r1 = await backend.capture({}) + const r2 = await backend.capture({}) + expect(r1.window_id).toBe(r2.window_id) + }) + + it('reports a clear error when the bridge fails its handshake', async () => { + backend = makeBackend({ + // Use process.env override via spawn options? Spawn doesn't + // accept env directly through our constructor; set it on + // the parent and unset after. + }) + // Tear down the default backend; build one with the env in place. + await backend.close() + process.env.AGENTMARK_FAKE_BRIDGE_FAIL_PING = '1' + try { + backend = makeBackend() + await expect(backend.capture({})).rejects.toThrow(/handshake failed/i) + } finally { + delete process.env.AGENTMARK_FAKE_BRIDGE_FAIL_PING + } + }) + + it('closes cleanly and rejects subsequent calls', async () => { + backend = makeBackend() + await backend.capture({}) + await backend.close() + await expect(backend.capture({})).rejects.toThrow(/closed/i) + }) + + it('rejects construction when bridgePath is not found and AGENTMARK_BRIDGE_PATH unset', () => { + // We avoid the path-walk fallback by NOT passing bridgePath and + // confirming the resolver throws cleanly. + const oldEnv = process.env.AGENTMARK_BRIDGE_PATH + delete process.env.AGENTMARK_BRIDGE_PATH + try { + // Use a path that definitely doesn't exist plus a guard so + // the failure mode is exercised. + expect(() => new WindowsUiaBackend({ + bridgePath: '/definitely/does/not/exist/agentmark-bridge-windows.exe', + allowNonWindows: true, + })).not.toThrow() + // The constructor doesn't probe the path; failure surfaces on first call. + } finally { + if (oldEnv !== undefined) process.env.AGENTMARK_BRIDGE_PATH = oldEnv + } + }) +}) diff --git a/test/mcp/desktop-dispatcher.test.ts b/test/mcp/desktop-dispatcher.test.ts index e1cefc3..d6866a9 100644 --- a/test/mcp/desktop-dispatcher.test.ts +++ b/test/mcp/desktop-dispatcher.test.ts @@ -56,9 +56,20 @@ describe('MCP — desktop tools', () => { expect(result.text).toContain('Unknown desktop backend') }) - it('agentmark_desktop_open with windows_uia returns "not yet bundled" (until the bridge ships)', async () => { + it('agentmark_desktop_open with windows_uia refuses to start on non-Windows platforms', async () => { + // On non-Windows: clear OS-mismatch error. + // On Windows: the dispatcher would attempt to spawn the bridge; that + // path is exercised in test/desktop/windows-uia-backend.test.ts. + if (process.platform === 'win32') return + const result = await dispatch(state, 'agentmark_desktop_open', { backend: 'windows_uia' }) expect(result.isError).toBe(true) + expect(result.text).toMatch(/requires Windows/i) + }) + + it('agentmark_desktop_open with macos_axapi still reports not-yet-bundled (bridge ships later)', async () => { + const result = await dispatch(state, 'agentmark_desktop_open', { backend: 'macos_axapi' }) + expect(result.isError).toBe(true) expect(result.text).toContain('not yet bundled') })