chore: import upstream snapshot with attribution
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
[bumpversion]
|
||||
current_version = 0.1.7
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = npm-computer-v{new_version}
|
||||
message = Bump @trycua/computer to v{new_version}
|
||||
|
||||
[bumpversion:file:package.json]
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
@@ -0,0 +1,6 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
|
||||
*.log
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright © 2025 Cua
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,5 @@
|
||||
# @trycua/computer
|
||||
|
||||
Computer-Use Interface (CUI) framework for interacting with local macOS and Linux sandboxes.
|
||||
|
||||
**[Documentation](https://cua.ai/docs/cua/reference/computer-sdk)** - Installation, guides, and configuration.
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@trycua/computer",
|
||||
"version": "0.1.7",
|
||||
"packageManager": "pnpm@10.11.0",
|
||||
"description": "Typescript SDK for Cua computer interaction",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/trycua/cua/tree/feature/computer/typescript/libs/typescript/computer",
|
||||
"bugs": {
|
||||
"url": "https://github.com/trycua/cua/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/trycua/cua.git"
|
||||
},
|
||||
"author": "cua",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "prettier --check .",
|
||||
"lint:fix": "prettier --write .",
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch",
|
||||
"test": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"release": "bumpp && pnpm publish",
|
||||
"prepublishOnly": "pnpm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trycua/core": "workspace:^",
|
||||
"pino": "^9.7.0",
|
||||
"uuid": "^11.1.1",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.17",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"bumpp": "^10.1.0",
|
||||
"happy-dom": "^20.8.9",
|
||||
"tsdown": "^0.11.9",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.1.0",
|
||||
"vite": "^7.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BaseComputer, CloudComputer } from './providers';
|
||||
@@ -0,0 +1,145 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import { Telemetry } from '@trycua/core';
|
||||
import pino from 'pino';
|
||||
import type { OSType } from '../../types';
|
||||
import type { BaseComputerConfig, Display, VMProviderType } from '../types';
|
||||
|
||||
const logger = pino({ name: 'computer.provider_base' });
|
||||
|
||||
/**
|
||||
* Hash API key using SHA256 for secure telemetry identification.
|
||||
*/
|
||||
function hashApiKey(apiKey: string | undefined): string | undefined {
|
||||
if (!apiKey) return undefined;
|
||||
return createHash('sha256').update(apiKey).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Base Computer class with shared functionality
|
||||
*/
|
||||
export abstract class BaseComputer {
|
||||
protected name: string;
|
||||
protected osType: OSType;
|
||||
protected vmProvider?: VMProviderType;
|
||||
protected telemetry: Telemetry;
|
||||
|
||||
constructor(config: BaseComputerConfig) {
|
||||
this.name = config.name;
|
||||
this.osType = config.osType;
|
||||
this.vmProvider = config.vmProvider;
|
||||
this.telemetry = new Telemetry();
|
||||
this.telemetry.recordEvent('module_init', {
|
||||
module: 'computer',
|
||||
version: process.env.npm_package_version,
|
||||
node_version: process.version,
|
||||
});
|
||||
|
||||
this.telemetry.recordEvent('computer_initialized', {
|
||||
os: os.platform(),
|
||||
os_version: os.version(),
|
||||
node_version: process.version,
|
||||
provider_type: config.vmProvider ?? 'unknown',
|
||||
});
|
||||
|
||||
// Track which config args were provided
|
||||
const argsProvided: string[] = ['name', 'osType']; // required args
|
||||
const apiKey = 'apiKey' in config ? (config as { apiKey?: string }).apiKey : undefined;
|
||||
if (apiKey) argsProvided.push('apiKey');
|
||||
if ('apiBase' in config) argsProvided.push('apiBase');
|
||||
if (config.vmProvider) argsProvided.push('vmProvider');
|
||||
|
||||
const initEventData: Record<string, unknown> = {
|
||||
os_type: config.osType,
|
||||
args_provided: argsProvided,
|
||||
provider_type: config.vmProvider ?? 'unknown',
|
||||
};
|
||||
// Add hashed API key
|
||||
if (apiKey) {
|
||||
initEventData.api_key_hash = hashApiKey(apiKey);
|
||||
}
|
||||
|
||||
this.telemetry.recordEvent('ts_computer_init', initEventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the computer
|
||||
*/
|
||||
getName(): string {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OS type of the computer
|
||||
*/
|
||||
getOSType(): OSType {
|
||||
return this.osType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the VM provider type
|
||||
*/
|
||||
getVMProviderType(): VMProviderType | undefined {
|
||||
return this.vmProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared method available to all computer types
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
logger.info(`Disconnecting from ${this.name}`);
|
||||
// Implementation would go here
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse display string into Display object
|
||||
* @param display Display string in format "WIDTHxHEIGHT"
|
||||
* @returns Display object
|
||||
*/
|
||||
public static parseDisplayString(display: string): Display {
|
||||
const match = display.match(/^(\d+)x(\d+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid display format: ${display}. Expected format: WIDTHxHEIGHT`);
|
||||
}
|
||||
|
||||
return {
|
||||
width: Number.parseInt(match[1], 10),
|
||||
height: Number.parseInt(match[2], 10),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse memory string to MB integer.
|
||||
*
|
||||
* Examples:
|
||||
* "8GB" -> 8192
|
||||
* "1024MB" -> 1024
|
||||
* "512" -> 512
|
||||
*
|
||||
* @param memoryStr - Memory string to parse
|
||||
* @returns Memory value in MB
|
||||
*/
|
||||
public static parseMemoryString(memoryStr: string): number {
|
||||
if (!memoryStr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Convert to uppercase for case-insensitive matching
|
||||
const upperStr = memoryStr.toUpperCase().trim();
|
||||
|
||||
// Extract numeric value and unit
|
||||
const match = upperStr.match(/^(\d+(?:\.\d+)?)\s*(GB|MB)?$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid memory format: ${memoryStr}`);
|
||||
}
|
||||
|
||||
const value = Number.parseFloat(match[1]);
|
||||
const unit = match[2] || 'MB'; // Default to MB if no unit specified
|
||||
|
||||
// Convert to MB
|
||||
if (unit === 'GB') {
|
||||
return Math.round(value * 1024);
|
||||
}
|
||||
return Math.round(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import pino from 'pino';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { cuaVersionHeaders } from '@trycua/core';
|
||||
import { type BaseComputerInterface, InterfaceFactory } from '../../interface/index';
|
||||
import type { CloudComputerConfig, VMProviderType } from '../types';
|
||||
import { BaseComputer } from './base';
|
||||
|
||||
/**
|
||||
* Hash API key using SHA256 for secure telemetry identification.
|
||||
*/
|
||||
function hashApiKey(apiKey: string): string {
|
||||
return createHash('sha256').update(apiKey).digest('hex');
|
||||
}
|
||||
|
||||
const DEFAULT_API_BASE = process.env.CUA_API_BASE || 'https://api.cua.ai';
|
||||
|
||||
interface VMInfo {
|
||||
name: string;
|
||||
host?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud-specific computer implementation
|
||||
*/
|
||||
export class CloudComputer extends BaseComputer {
|
||||
protected static vmProviderType: VMProviderType.CLOUD;
|
||||
protected apiKey: string;
|
||||
private iface?: BaseComputerInterface;
|
||||
private initialized = false;
|
||||
private cachedHost?: string;
|
||||
private apiBase: string;
|
||||
|
||||
// Session tracking
|
||||
private sessionId: string;
|
||||
private sessionStartTime?: number;
|
||||
private apiKeyHash: string;
|
||||
|
||||
protected logger = pino({ name: 'computer.provider_cloud' });
|
||||
|
||||
constructor(config: CloudComputerConfig) {
|
||||
super(config);
|
||||
this.apiKey = config.apiKey;
|
||||
this.apiBase = DEFAULT_API_BASE;
|
||||
this.sessionId = uuidv4();
|
||||
this.apiKeyHash = hashApiKey(config.apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the host for this VM.
|
||||
* Returns cached host if available, otherwise falls back to default format.
|
||||
*/
|
||||
get ip(): string {
|
||||
return this.cachedHost || `${this.name}.sandbox.cua.ai`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch VM list from API and cache the host for this VM.
|
||||
*/
|
||||
private async fetchAndCacheHost(): Promise<string> {
|
||||
try {
|
||||
const response = await fetch(`${this.apiBase}/v1/vms`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
...cuaVersionHeaders(
|
||||
'computer',
|
||||
typeof __CUA_VERSION__ !== 'undefined' ? __CUA_VERSION__ : ''
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const vms = (await response.json()) as VMInfo[];
|
||||
const vm = vms.find((v) => v.name === this.name);
|
||||
if (vm?.host) {
|
||||
this.cachedHost = vm.host;
|
||||
this.logger.info(`Cached host from API: ${this.cachedHost}`);
|
||||
return this.cachedHost;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to fetch VM list for host lookup: ${error}`);
|
||||
}
|
||||
|
||||
// Fall back to default format
|
||||
const fallbackHost = `${this.name}.sandbox.cua.ai`;
|
||||
this.cachedHost = fallbackHost;
|
||||
this.logger.info(`Using fallback host: ${fallbackHost}`);
|
||||
return fallbackHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the cloud VM and interface
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
this.logger.info('Computer already initialized, skipping initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch the host from API before connecting
|
||||
const ipAddress = await this.fetchAndCacheHost();
|
||||
this.logger.info(`Connecting to cloud VM at ${ipAddress}`);
|
||||
|
||||
// Create the interface with API key authentication
|
||||
this.iface = InterfaceFactory.createInterfaceForOS(
|
||||
this.osType,
|
||||
ipAddress,
|
||||
this.apiKey,
|
||||
this.name
|
||||
);
|
||||
|
||||
// Wait for the interface to be ready
|
||||
this.logger.info('Waiting for interface to be ready...');
|
||||
await this.iface.waitForReady();
|
||||
|
||||
this.initialized = true;
|
||||
this.sessionStartTime = Date.now();
|
||||
|
||||
// Track session start with hashed API key
|
||||
this.telemetry.recordEvent('ts_session_start', {
|
||||
session_id: this.sessionId,
|
||||
os_type: this.osType,
|
||||
connection_type: 'cloud',
|
||||
api_key_hash: this.apiKeyHash,
|
||||
});
|
||||
|
||||
this.logger.info('Cloud computer ready');
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to initialize cloud computer: ${error}`);
|
||||
throw new Error(`Failed to initialize cloud computer: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cloud computer (disconnect interface)
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
this.logger.info('Disconnecting from cloud computer...');
|
||||
|
||||
// Track session end with hashed API key
|
||||
if (this.sessionStartTime) {
|
||||
const durationSeconds = (Date.now() - this.sessionStartTime) / 1000;
|
||||
this.telemetry.recordEvent('ts_session_end', {
|
||||
session_id: this.sessionId,
|
||||
duration_seconds: durationSeconds,
|
||||
api_key_hash: this.apiKeyHash,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.iface) {
|
||||
this.iface.disconnect();
|
||||
this.iface = undefined;
|
||||
}
|
||||
|
||||
this.initialized = false;
|
||||
this.logger.info('Disconnected from cloud computer');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the computer interface
|
||||
*/
|
||||
get interface(): BaseComputerInterface {
|
||||
if (!this.iface) {
|
||||
throw new Error('Computer not initialized. Call run() first.');
|
||||
}
|
||||
return this.iface;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the cloud computer
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
await this.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './base';
|
||||
export * from './cloud';
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { OSType, ScreenSize } from '../types';
|
||||
|
||||
/**
|
||||
* Display configuration for the computer.
|
||||
*/
|
||||
export interface Display extends ScreenSize {
|
||||
scale_factor?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computer configuration model.
|
||||
*/
|
||||
export interface BaseComputerConfig {
|
||||
/**
|
||||
* The VM name
|
||||
* @default ""
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The operating system type ('macos', 'windows', or 'linux')
|
||||
* @default "macos"
|
||||
*/
|
||||
osType: OSType;
|
||||
|
||||
/**
|
||||
* The VM provider type
|
||||
*/
|
||||
vmProvider?: VMProviderType;
|
||||
}
|
||||
|
||||
export interface CloudComputerConfig extends BaseComputerConfig {
|
||||
/**
|
||||
* Optional API key for cloud providers
|
||||
*/
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export enum VMProviderType {
|
||||
DOCKER = 'docker',
|
||||
LUME = 'lume',
|
||||
CLOUD = 'cloud',
|
||||
QEMU = 'qemu',
|
||||
WINDOWS_SANDBOX = 'windows-sandbox',
|
||||
HOST = 'host',
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
/** Build-time constant injected by tsdown `define`. */
|
||||
declare const __CUA_VERSION__: string;
|
||||
@@ -0,0 +1,5 @@
|
||||
// Export classes
|
||||
export { CloudComputer as Computer } from './computer';
|
||||
|
||||
export { OSType } from './types';
|
||||
export { VMProviderType as ProviderType } from './computer/types';
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Base interface for computer control.
|
||||
*/
|
||||
|
||||
import pino from 'pino';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import WebSocket from 'ws';
|
||||
import { Telemetry } from '@trycua/core';
|
||||
import type { ScreenSize } from '../types';
|
||||
|
||||
export type MouseButton = 'left' | 'middle' | 'right';
|
||||
|
||||
export interface CursorPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface AccessibilityNode {
|
||||
role: string;
|
||||
title?: string;
|
||||
value?: string;
|
||||
description?: string;
|
||||
bounds?: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
children?: AccessibilityNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for computer control interfaces.
|
||||
*/
|
||||
export abstract class BaseComputerInterface {
|
||||
protected ipAddress: string;
|
||||
protected username: string;
|
||||
protected password: string;
|
||||
protected closed = false;
|
||||
protected commandLock: Promise<unknown> = Promise.resolve();
|
||||
protected ws: WebSocket;
|
||||
protected apiKey?: string;
|
||||
protected vmName?: string;
|
||||
|
||||
protected logger = pino({ name: 'computer.interface-base' });
|
||||
|
||||
// Telemetry
|
||||
protected telemetry: Telemetry;
|
||||
protected sessionId: string;
|
||||
|
||||
constructor(
|
||||
ipAddress: string,
|
||||
username = 'lume',
|
||||
password = 'lume',
|
||||
apiKey?: string,
|
||||
vmName?: string
|
||||
) {
|
||||
this.ipAddress = ipAddress;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.apiKey = apiKey;
|
||||
this.vmName = vmName;
|
||||
|
||||
// Initialize telemetry
|
||||
this.telemetry = new Telemetry();
|
||||
this.sessionId = uuidv4();
|
||||
|
||||
// Initialize WebSocket with headers if needed
|
||||
const headers: { [key: string]: string } = {};
|
||||
if (this.apiKey && this.vmName) {
|
||||
headers['X-API-Key'] = this.apiKey;
|
||||
headers['X-VM-Name'] = this.vmName;
|
||||
}
|
||||
|
||||
// Create the WebSocket instance
|
||||
this.ws = this.createWebSocket(headers);
|
||||
}
|
||||
|
||||
private createWebSocket(headers: { [key: string]: string }): WebSocket {
|
||||
const ws = new WebSocket(this.wsUri, { headers });
|
||||
// Constructors open immediately, and some tests only verify inheritance
|
||||
// without ever calling connect(). Keep those failed background attempts
|
||||
// from surfacing as unhandled process errors; connect() still attaches its
|
||||
// own one-shot error listener and rejects active connection attempts.
|
||||
ws.on('error', (error: Error) => {
|
||||
this.logger.debug(`WebSocket background error: ${error.message}`);
|
||||
});
|
||||
return ws;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the WebSocket URI for connection.
|
||||
* Subclasses can override this to customize the URI.
|
||||
*/
|
||||
protected get wsUri(): string {
|
||||
const protocol = this.apiKey ? 'wss' : 'ws';
|
||||
|
||||
// Check if ipAddress already includes a port
|
||||
if (this.ipAddress.includes(':')) {
|
||||
return `${protocol}://${this.ipAddress}/ws`;
|
||||
}
|
||||
|
||||
// Otherwise, append the default port
|
||||
const port = this.apiKey ? '8443' : '8000';
|
||||
return `${protocol}://${this.ipAddress}:${port}/ws`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for interface to be ready.
|
||||
* @param timeout Maximum time to wait in seconds
|
||||
* @throws Error if interface is not ready within timeout
|
||||
*/
|
||||
async waitForReady(timeout = 60): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout * 1000) {
|
||||
try {
|
||||
await this.connect();
|
||||
return;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// Wait a bit before retrying
|
||||
this.logger.error(`Error connecting to websocket: ${JSON.stringify(error)}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Interface not ready after ${timeout} seconds`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with the WebSocket server.
|
||||
* This should be called immediately after the WebSocket connection is established.
|
||||
*/
|
||||
private async authenticate(): Promise<void> {
|
||||
if (!this.apiKey || !this.vmName) {
|
||||
// No authentication needed
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info('Performing authentication handshake...');
|
||||
const authMessage = {
|
||||
command: 'authenticate',
|
||||
params: {
|
||||
api_key: this.apiKey,
|
||||
container_name: this.vmName,
|
||||
},
|
||||
};
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const authHandler = (data: WebSocket.RawData) => {
|
||||
try {
|
||||
const authResult = JSON.parse(data.toString());
|
||||
if (!authResult.success) {
|
||||
const errorMsg = authResult.error || 'Authentication failed';
|
||||
this.logger.error(`Authentication failed: ${errorMsg}`);
|
||||
this.ws.close();
|
||||
reject(new Error(`Authentication failed: ${errorMsg}`));
|
||||
} else {
|
||||
this.logger.info('Authentication successful');
|
||||
this.ws.off('message', authHandler);
|
||||
resolve();
|
||||
}
|
||||
} catch (error) {
|
||||
this.ws.off('message', authHandler);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.on('message', authHandler);
|
||||
this.ws.send(JSON.stringify(authMessage));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the WebSocket server.
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
// If the WebSocket is already open, check if we need to authenticate
|
||||
if (this.ws.readyState === WebSocket.OPEN) {
|
||||
this.logger.info('Websocket is open, ensuring authentication is complete.');
|
||||
this.closed = false;
|
||||
return this.authenticate();
|
||||
}
|
||||
|
||||
// If the WebSocket is closed or closing, reinitialize it
|
||||
if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
|
||||
this.logger.info('Websocket is closed. Reinitializing connection.');
|
||||
const headers: { [key: string]: string } = {};
|
||||
if (this.apiKey && this.vmName) {
|
||||
headers['X-API-Key'] = this.apiKey;
|
||||
headers['X-VM-Name'] = this.vmName;
|
||||
}
|
||||
this.ws = this.createWebSocket(headers);
|
||||
}
|
||||
|
||||
// Connect and authenticate
|
||||
return new Promise((resolve, reject) => {
|
||||
const onOpen = async () => {
|
||||
try {
|
||||
this.closed = false;
|
||||
// Always authenticate immediately after connection
|
||||
await this.authenticate();
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
// If already connecting, wait for it to complete then authenticate
|
||||
if (this.ws.readyState === WebSocket.CONNECTING) {
|
||||
this.ws.addEventListener('open', onOpen, { once: true });
|
||||
this.ws.addEventListener('error', (error) => reject(error), {
|
||||
once: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up event handlers
|
||||
this.ws.on('open', onOpen);
|
||||
|
||||
this.ws.on('error', (error: Error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
if (!this.closed) {
|
||||
// Attempt to reconnect
|
||||
setTimeout(() => {
|
||||
this.connect().catch((error) => {
|
||||
this.logger.error(`Error reconnecting to websocket: ${JSON.stringify(error)}`);
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to the WebSocket server.
|
||||
*/
|
||||
public async sendCommand(
|
||||
command: string,
|
||||
params: { [key: string]: unknown } = {}
|
||||
): Promise<{ [key: string]: unknown }> {
|
||||
// Track action execution
|
||||
this.telemetry.recordEvent('ts_action_executed', {
|
||||
action_type: command,
|
||||
session_id: this.sessionId,
|
||||
});
|
||||
|
||||
// Create a new promise for this specific command
|
||||
const commandPromise = new Promise<{ [key: string]: unknown }>((resolve, reject) => {
|
||||
// Chain it to the previous commands
|
||||
const executeCommand = async (): Promise<{
|
||||
[key: string]: unknown;
|
||||
}> => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
return new Promise<{ [key: string]: unknown }>((innerResolve, innerReject) => {
|
||||
const messageHandler = (data: WebSocket.RawData) => {
|
||||
try {
|
||||
const response = JSON.parse(data.toString());
|
||||
if (response.error) {
|
||||
innerReject(new Error(response.error));
|
||||
} else {
|
||||
innerResolve(response);
|
||||
}
|
||||
} catch (error) {
|
||||
innerReject(error);
|
||||
}
|
||||
this.ws.off('message', messageHandler);
|
||||
};
|
||||
|
||||
this.ws.on('message', messageHandler);
|
||||
const wsCommand = { command, params };
|
||||
this.ws.send(JSON.stringify(wsCommand));
|
||||
});
|
||||
};
|
||||
|
||||
// Add this command to the lock chain
|
||||
this.commandLock = this.commandLock.then(() => executeCommand().then(resolve, reject));
|
||||
});
|
||||
|
||||
return commandPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the WebSocket is connected.
|
||||
*/
|
||||
public isConnected(): boolean {
|
||||
return this.ws && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the interface connection.
|
||||
*/
|
||||
disconnect(): void {
|
||||
this.closed = true;
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.close();
|
||||
} else if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
||||
// If still connecting, terminate the connection attempt
|
||||
this.ws.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force close the interface connection.
|
||||
* By default, this just calls close(), but subclasses can override
|
||||
* to provide more forceful cleanup.
|
||||
*/
|
||||
forceClose(): void {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
// Mouse Actions
|
||||
abstract mouseDown(x?: number, y?: number, button?: MouseButton): Promise<void>;
|
||||
abstract mouseUp(x?: number, y?: number, button?: MouseButton): Promise<void>;
|
||||
abstract leftClick(x?: number, y?: number): Promise<void>;
|
||||
abstract rightClick(x?: number, y?: number): Promise<void>;
|
||||
abstract doubleClick(x?: number, y?: number): Promise<void>;
|
||||
abstract moveCursor(x: number, y: number): Promise<void>;
|
||||
abstract dragTo(x: number, y: number, button?: MouseButton, duration?: number): Promise<void>;
|
||||
abstract drag(
|
||||
path: Array<[number, number]>,
|
||||
button?: MouseButton,
|
||||
duration?: number
|
||||
): Promise<void>;
|
||||
|
||||
// Keyboard Actions
|
||||
abstract keyDown(key: string): Promise<void>;
|
||||
abstract keyUp(key: string): Promise<void>;
|
||||
abstract typeText(text: string): Promise<void>;
|
||||
abstract pressKey(key: string): Promise<void>;
|
||||
abstract hotkey(...keys: string[]): Promise<void>;
|
||||
|
||||
// Scrolling Actions
|
||||
abstract scroll(x: number, y: number): Promise<void>;
|
||||
abstract scrollDown(clicks?: number): Promise<void>;
|
||||
abstract scrollUp(clicks?: number): Promise<void>;
|
||||
|
||||
// Screen Actions
|
||||
abstract screenshot(): Promise<Buffer>;
|
||||
abstract getScreenSize(): Promise<ScreenSize>;
|
||||
abstract getCursorPosition(): Promise<CursorPosition>;
|
||||
|
||||
// Window Management
|
||||
abstract open(target: string): Promise<void>;
|
||||
abstract launch(app: string, args?: string[]): Promise<number | undefined>;
|
||||
abstract getCurrentWindowId(): Promise<number | string>;
|
||||
abstract getApplicationWindows(app: string): Promise<Array<number | string>>;
|
||||
abstract getWindowName(windowId: number | string): Promise<string>;
|
||||
abstract getWindowSize(windowId: number | string): Promise<[number, number]>;
|
||||
abstract getWindowPosition(windowId: number | string): Promise<[number, number]>;
|
||||
abstract setWindowSize(windowId: number | string, width: number, height: number): Promise<void>;
|
||||
abstract setWindowPosition(windowId: number | string, x: number, y: number): Promise<void>;
|
||||
abstract maximizeWindow(windowId: number | string): Promise<void>;
|
||||
abstract minimizeWindow(windowId: number | string): Promise<void>;
|
||||
abstract activateWindow(windowId: number | string): Promise<void>;
|
||||
abstract closeWindow(windowId: number | string): Promise<void>;
|
||||
|
||||
// Desktop Actions
|
||||
abstract getDesktopEnvironment(): Promise<string>;
|
||||
abstract setWallpaper(path: string): Promise<void>;
|
||||
|
||||
// Clipboard Actions
|
||||
abstract copyToClipboard(): Promise<string>;
|
||||
abstract setClipboard(text: string): Promise<void>;
|
||||
|
||||
// File System Actions
|
||||
abstract fileExists(path: string): Promise<boolean>;
|
||||
abstract directoryExists(path: string): Promise<boolean>;
|
||||
abstract listDir(path: string): Promise<string[]>;
|
||||
abstract readText(path: string): Promise<string>;
|
||||
abstract writeText(path: string, content: string): Promise<void>;
|
||||
abstract readBytes(path: string): Promise<Buffer>;
|
||||
abstract writeBytes(path: string, content: Buffer): Promise<void>;
|
||||
abstract deleteFile(path: string): Promise<void>;
|
||||
abstract createDir(path: string): Promise<void>;
|
||||
abstract deleteDir(path: string): Promise<void>;
|
||||
abstract runCommand(command: string): Promise<[string, string]>;
|
||||
|
||||
// Accessibility Actions
|
||||
abstract getAccessibilityTree(): Promise<AccessibilityNode>;
|
||||
abstract toScreenCoordinates(x: number, y: number): Promise<[number, number]>;
|
||||
abstract toScreenshotCoordinates(x: number, y: number): Promise<[number, number]>;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Factory for creating computer interfaces.
|
||||
*/
|
||||
|
||||
import type { OSType } from '../types';
|
||||
import type { BaseComputerInterface } from './base';
|
||||
import { LinuxComputerInterface } from './linux';
|
||||
import { MacOSComputerInterface } from './macos';
|
||||
import { WindowsComputerInterface } from './windows';
|
||||
|
||||
export const InterfaceFactory = {
|
||||
/**
|
||||
* Create an interface for the specified OS.
|
||||
*
|
||||
* @param os Operating system type ('macos', 'linux', or 'windows')
|
||||
* @param ipAddress IP address of the computer to control
|
||||
* @param apiKey Optional API key for cloud authentication
|
||||
* @param vmName Optional VM name for cloud authentication
|
||||
* @returns The appropriate interface for the OS
|
||||
* @throws Error if the OS type is not supported
|
||||
*/
|
||||
createInterfaceForOS(
|
||||
os: OSType,
|
||||
ipAddress: string,
|
||||
apiKey?: string,
|
||||
vmName?: string
|
||||
): BaseComputerInterface {
|
||||
switch (os) {
|
||||
case 'macos':
|
||||
return new MacOSComputerInterface(ipAddress, 'lume', 'lume', apiKey, vmName);
|
||||
case 'linux':
|
||||
return new LinuxComputerInterface(ipAddress, 'lume', 'lume', apiKey, vmName);
|
||||
case 'windows':
|
||||
return new WindowsComputerInterface(ipAddress, 'lume', 'lume', apiKey, vmName);
|
||||
default:
|
||||
throw new Error(`Unsupported OS type: ${os}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export { BaseComputerInterface } from './base';
|
||||
export type { MouseButton, CursorPosition, AccessibilityNode } from './base';
|
||||
export { InterfaceFactory } from './factory';
|
||||
export { MacOSComputerInterface } from './macos';
|
||||
export { LinuxComputerInterface } from './linux';
|
||||
export { WindowsComputerInterface } from './windows';
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Linux computer interface implementation.
|
||||
*/
|
||||
|
||||
import { MacOSComputerInterface } from './macos';
|
||||
|
||||
/**
|
||||
* Linux interface implementation.
|
||||
* Since the cloud provider uses the same WebSocket protocol for all OS types,
|
||||
* we can reuse the macOS implementation.
|
||||
*/
|
||||
export class LinuxComputerInterface extends MacOSComputerInterface {
|
||||
// Linux uses the same WebSocket interface as macOS for cloud provider
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
/**
|
||||
* macOS computer interface implementation.
|
||||
*/
|
||||
|
||||
import type { ScreenSize } from '../types';
|
||||
import type { AccessibilityNode, CursorPosition, MouseButton } from './base';
|
||||
import { BaseComputerInterface } from './base';
|
||||
|
||||
export class MacOSComputerInterface extends BaseComputerInterface {
|
||||
// Mouse Actions
|
||||
/**
|
||||
* Press and hold a mouse button at the specified coordinates.
|
||||
* @param {number} [x] - X coordinate for the mouse action
|
||||
* @param {number} [y] - Y coordinate for the mouse action
|
||||
* @param {MouseButton} [button='left'] - Mouse button to press down
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async mouseDown(x?: number, y?: number, button: MouseButton = 'left'): Promise<void> {
|
||||
await this.sendCommand('mouse_down', { x, y, button });
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a mouse button at the specified coordinates.
|
||||
* @param {number} [x] - X coordinate for the mouse action
|
||||
* @param {number} [y] - Y coordinate for the mouse action
|
||||
* @param {MouseButton} [button='left'] - Mouse button to release
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async mouseUp(x?: number, y?: number, button: MouseButton = 'left'): Promise<void> {
|
||||
await this.sendCommand('mouse_up', { x, y, button });
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a left mouse click at the specified coordinates.
|
||||
* @param {number} [x] - X coordinate for the click
|
||||
* @param {number} [y] - Y coordinate for the click
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async leftClick(x?: number, y?: number): Promise<void> {
|
||||
await this.sendCommand('left_click', { x, y });
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a right mouse click at the specified coordinates.
|
||||
* @param {number} [x] - X coordinate for the click
|
||||
* @param {number} [y] - Y coordinate for the click
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async rightClick(x?: number, y?: number): Promise<void> {
|
||||
await this.sendCommand('right_click', { x, y });
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a double click at the specified coordinates.
|
||||
* @param {number} [x] - X coordinate for the double click
|
||||
* @param {number} [y] - Y coordinate for the double click
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async doubleClick(x?: number, y?: number): Promise<void> {
|
||||
await this.sendCommand('double_click', { x, y });
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the cursor to the specified coordinates.
|
||||
* @param {number} x - X coordinate to move to
|
||||
* @param {number} y - Y coordinate to move to
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async moveCursor(x: number, y: number): Promise<void> {
|
||||
await this.sendCommand('move_cursor', { x, y });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag from current position to the specified coordinates.
|
||||
* @param {number} x - X coordinate to drag to
|
||||
* @param {number} y - Y coordinate to drag to
|
||||
* @param {MouseButton} [button='left'] - Mouse button to use for dragging
|
||||
* @param {number} [duration=0.5] - Duration of the drag operation in seconds
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async dragTo(x: number, y: number, button: MouseButton = 'left', duration = 0.5): Promise<void> {
|
||||
await this.sendCommand('drag_to', { x, y, button, duration });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag along a path of coordinates.
|
||||
* @param {Array<[number, number]>} path - Array of [x, y] coordinate pairs to drag through
|
||||
* @param {MouseButton} [button='left'] - Mouse button to use for dragging
|
||||
* @param {number} [duration=0.5] - Duration of the drag operation in seconds
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async drag(
|
||||
path: Array<[number, number]>,
|
||||
button: MouseButton = 'left',
|
||||
duration = 0.5
|
||||
): Promise<void> {
|
||||
await this.sendCommand('drag', { path, button, duration });
|
||||
}
|
||||
|
||||
// Keyboard Actions
|
||||
/**
|
||||
* Press and hold a key.
|
||||
* @param {string} key - Key to press down
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async keyDown(key: string): Promise<void> {
|
||||
await this.sendCommand('key_down', { key });
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a key.
|
||||
* @param {string} key - Key to release
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async keyUp(key: string): Promise<void> {
|
||||
await this.sendCommand('key_up', { key });
|
||||
}
|
||||
|
||||
/**
|
||||
* Type text as if entered from keyboard.
|
||||
* @param {string} text - Text to type
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async typeText(text: string): Promise<void> {
|
||||
await this.sendCommand('type_text', { text });
|
||||
}
|
||||
|
||||
/**
|
||||
* Press and release a key.
|
||||
* @param {string} key - Key to press
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async pressKey(key: string): Promise<void> {
|
||||
await this.sendCommand('press_key', { key });
|
||||
}
|
||||
|
||||
/**
|
||||
* Press multiple keys simultaneously as a hotkey combination.
|
||||
* @param {...string} keys - Keys to press together
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async hotkey(...keys: string[]): Promise<void> {
|
||||
await this.sendCommand('hotkey', { keys });
|
||||
}
|
||||
|
||||
// Scrolling Actions
|
||||
/**
|
||||
* Scroll by the specified amount in x and y directions.
|
||||
* @param {number} x - Horizontal scroll amount
|
||||
* @param {number} y - Vertical scroll amount
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async scroll(x: number, y: number): Promise<void> {
|
||||
await this.sendCommand('scroll', { x, y });
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll down by the specified number of clicks.
|
||||
* @param {number} [clicks=1] - Number of scroll clicks
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async scrollDown(clicks = 1): Promise<void> {
|
||||
await this.sendCommand('scroll_down', { clicks });
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll up by the specified number of clicks.
|
||||
* @param {number} [clicks=1] - Number of scroll clicks
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async scrollUp(clicks = 1): Promise<void> {
|
||||
await this.sendCommand('scroll_up', { clicks });
|
||||
}
|
||||
|
||||
// Screen Actions
|
||||
/**
|
||||
* Take a screenshot of the screen.
|
||||
* @returns {Promise<Buffer>} Screenshot image data as a Buffer
|
||||
* @throws {Error} If screenshot fails
|
||||
*/
|
||||
async screenshot(): Promise<Buffer> {
|
||||
const response = await this.sendCommand('screenshot');
|
||||
if (!response.image_data) {
|
||||
throw new Error('Failed to take screenshot');
|
||||
}
|
||||
return Buffer.from(response.image_data as string, 'base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current screen size.
|
||||
* @returns {Promise<ScreenSize>} Screen dimensions
|
||||
* @throws {Error} If unable to get screen size
|
||||
*/
|
||||
async getScreenSize(): Promise<ScreenSize> {
|
||||
const response = await this.sendCommand('get_screen_size');
|
||||
if (!response.success || !response.size) {
|
||||
throw new Error('Failed to get screen size');
|
||||
}
|
||||
return response.size as ScreenSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current cursor position.
|
||||
* @returns {Promise<CursorPosition>} Current cursor coordinates
|
||||
* @throws {Error} If unable to get cursor position
|
||||
*/
|
||||
async getCursorPosition(): Promise<CursorPosition> {
|
||||
const response = await this.sendCommand('get_cursor_position');
|
||||
if (!response.success || !response.position) {
|
||||
throw new Error('Failed to get cursor position');
|
||||
}
|
||||
return response.position as CursorPosition;
|
||||
}
|
||||
|
||||
// Window Management
|
||||
/** Open a file path or URL with the default handler. */
|
||||
async open(target: string): Promise<void> {
|
||||
const response = await this.sendCommand('open', { target });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to open target');
|
||||
}
|
||||
}
|
||||
|
||||
/** Launch an application (string may include args). Returns pid if available. */
|
||||
async launch(app: string, args?: string[]): Promise<number | undefined> {
|
||||
const response = await this.sendCommand('launch', args ? { app, args } : { app });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to launch application');
|
||||
}
|
||||
return (response.pid as number) || undefined;
|
||||
}
|
||||
|
||||
/** Get the current active window id. */
|
||||
async getCurrentWindowId(): Promise<number | string> {
|
||||
const response = await this.sendCommand('get_current_window_id');
|
||||
if (!response.success || response.window_id === undefined) {
|
||||
throw new Error((response.error as string) || 'Failed to get current window id');
|
||||
}
|
||||
return response.window_id as number | string;
|
||||
}
|
||||
|
||||
/** Get windows belonging to an application (by name). */
|
||||
async getApplicationWindows(app: string): Promise<Array<number | string>> {
|
||||
const response = await this.sendCommand('get_application_windows', { app });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to get application windows');
|
||||
}
|
||||
return (response.windows as Array<number | string>) || [];
|
||||
}
|
||||
|
||||
/** Get window title/name by id. */
|
||||
async getWindowName(windowId: number | string): Promise<string> {
|
||||
const response = await this.sendCommand('get_window_name', { window_id: windowId });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to get window name');
|
||||
}
|
||||
return (response.name as string) || '';
|
||||
}
|
||||
|
||||
/** Get window size as [width, height]. */
|
||||
async getWindowSize(windowId: number | string): Promise<[number, number]> {
|
||||
const response = await this.sendCommand('get_window_size', { window_id: windowId });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to get window size');
|
||||
}
|
||||
return [Number(response.width) || 0, Number(response.height) || 0];
|
||||
}
|
||||
|
||||
/** Get window position as [x, y]. */
|
||||
async getWindowPosition(windowId: number | string): Promise<[number, number]> {
|
||||
const response = await this.sendCommand('get_window_position', { window_id: windowId });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to get window position');
|
||||
}
|
||||
return [Number(response.x) || 0, Number(response.y) || 0];
|
||||
}
|
||||
|
||||
/** Set window size. */
|
||||
async setWindowSize(windowId: number | string, width: number, height: number): Promise<void> {
|
||||
const response = await this.sendCommand('set_window_size', {
|
||||
window_id: windowId,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to set window size');
|
||||
}
|
||||
}
|
||||
|
||||
/** Set window position. */
|
||||
async setWindowPosition(windowId: number | string, x: number, y: number): Promise<void> {
|
||||
const response = await this.sendCommand('set_window_position', {
|
||||
window_id: windowId,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to set window position');
|
||||
}
|
||||
}
|
||||
|
||||
/** Maximize a window. */
|
||||
async maximizeWindow(windowId: number | string): Promise<void> {
|
||||
const response = await this.sendCommand('maximize_window', { window_id: windowId });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to maximize window');
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimize a window. */
|
||||
async minimizeWindow(windowId: number | string): Promise<void> {
|
||||
const response = await this.sendCommand('minimize_window', { window_id: windowId });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to minimize window');
|
||||
}
|
||||
}
|
||||
|
||||
/** Activate a window by id. */
|
||||
async activateWindow(windowId: number | string): Promise<void> {
|
||||
const response = await this.sendCommand('activate_window', { window_id: windowId });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to activate window');
|
||||
}
|
||||
}
|
||||
|
||||
/** Close a window by id. */
|
||||
async closeWindow(windowId: number | string): Promise<void> {
|
||||
const response = await this.sendCommand('close_window', { window_id: windowId });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to close window');
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop Actions
|
||||
/**
|
||||
* Get the current desktop environment string (e.g., 'xfce4', 'gnome', 'kde', 'mac', 'windows').
|
||||
*/
|
||||
async getDesktopEnvironment(): Promise<string> {
|
||||
const response = await this.sendCommand('get_desktop_environment');
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to get desktop environment');
|
||||
}
|
||||
return (response.environment as string) || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the desktop wallpaper image.
|
||||
* @param path Absolute path to the image file on the VM
|
||||
*/
|
||||
async setWallpaper(path: string): Promise<void> {
|
||||
const response = await this.sendCommand('set_wallpaper', { path });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to set wallpaper');
|
||||
}
|
||||
}
|
||||
|
||||
// Clipboard Actions
|
||||
/**
|
||||
* Copy current selection to clipboard and return the content.
|
||||
* @returns {Promise<string>} Clipboard content
|
||||
* @throws {Error} If unable to get clipboard content
|
||||
*/
|
||||
async copyToClipboard(): Promise<string> {
|
||||
const response = await this.sendCommand('copy_to_clipboard');
|
||||
if (!response.success || !response.content) {
|
||||
throw new Error('Failed to get clipboard content');
|
||||
}
|
||||
return response.content as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the clipboard content to the specified text.
|
||||
* @param {string} text - Text to set in clipboard
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setClipboard(text: string): Promise<void> {
|
||||
await this.sendCommand('set_clipboard', { text });
|
||||
}
|
||||
|
||||
// File System Actions
|
||||
/**
|
||||
* Check if a file exists at the specified path.
|
||||
* @param {string} path - Path to the file
|
||||
* @returns {Promise<boolean>} True if file exists, false otherwise
|
||||
*/
|
||||
async fileExists(path: string): Promise<boolean> {
|
||||
const response = await this.sendCommand('file_exists', { path });
|
||||
return (response.exists as boolean) || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory exists at the specified path.
|
||||
* @param {string} path - Path to the directory
|
||||
* @returns {Promise<boolean>} True if directory exists, false otherwise
|
||||
*/
|
||||
async directoryExists(path: string): Promise<boolean> {
|
||||
const response = await this.sendCommand('directory_exists', { path });
|
||||
return (response.exists as boolean) || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* List the contents of a directory.
|
||||
* @param {string} path - Path to the directory
|
||||
* @returns {Promise<string[]>} Array of file and directory names
|
||||
* @throws {Error} If unable to list directory
|
||||
*/
|
||||
async listDir(path: string): Promise<string[]> {
|
||||
const response = await this.sendCommand('list_dir', { path });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to list directory');
|
||||
}
|
||||
return (response.files as string[]) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of a file in bytes.
|
||||
* @param {string} path - Path to the file
|
||||
* @returns {Promise<number>} File size in bytes
|
||||
* @throws {Error} If unable to get file size
|
||||
*/
|
||||
async getFileSize(path: string): Promise<number> {
|
||||
const response = await this.sendCommand('get_file_size', { path });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to get file size');
|
||||
}
|
||||
return (response.size as number) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content in chunks for large files.
|
||||
* @private
|
||||
* @param {string} path - Path to the file
|
||||
* @param {number} offset - Starting byte offset
|
||||
* @param {number} totalLength - Total number of bytes to read
|
||||
* @param {number} [chunkSize=1048576] - Size of each chunk in bytes
|
||||
* @returns {Promise<Buffer>} File content as Buffer
|
||||
* @throws {Error} If unable to read file chunk
|
||||
*/
|
||||
private async readBytesChunked(
|
||||
path: string,
|
||||
offset: number,
|
||||
totalLength: number,
|
||||
chunkSize: number = 1024 * 1024
|
||||
): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
let currentOffset = offset;
|
||||
let remaining = totalLength;
|
||||
|
||||
while (remaining > 0) {
|
||||
const readSize = Math.min(chunkSize, remaining);
|
||||
const response = await this.sendCommand('read_bytes', {
|
||||
path,
|
||||
offset: currentOffset,
|
||||
length: readSize,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to read file chunk');
|
||||
}
|
||||
|
||||
const chunkData = Buffer.from(response.content_b64 as string, 'base64');
|
||||
chunks.push(chunkData);
|
||||
|
||||
currentOffset += readSize;
|
||||
remaining -= readSize;
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write file content in chunks for large files.
|
||||
* @private
|
||||
* @param {string} path - Path to the file
|
||||
* @param {Buffer} content - Content to write
|
||||
* @param {boolean} [append=false] - Whether to append to existing file
|
||||
* @param {number} [chunkSize=1048576] - Size of each chunk in bytes
|
||||
* @returns {Promise<void>}
|
||||
* @throws {Error} If unable to write file chunk
|
||||
*/
|
||||
private async writeBytesChunked(
|
||||
path: string,
|
||||
content: Buffer,
|
||||
append: boolean = false,
|
||||
chunkSize: number = 1024 * 1024
|
||||
): Promise<void> {
|
||||
const totalSize = content.length;
|
||||
let currentOffset = 0;
|
||||
|
||||
while (currentOffset < totalSize) {
|
||||
const chunkEnd = Math.min(currentOffset + chunkSize, totalSize);
|
||||
const chunkData = content.subarray(currentOffset, chunkEnd);
|
||||
|
||||
// First chunk uses the original append flag, subsequent chunks always append
|
||||
const chunkAppend = currentOffset === 0 ? append : true;
|
||||
|
||||
const response = await this.sendCommand('write_bytes', {
|
||||
path,
|
||||
content_b64: chunkData.toString('base64'),
|
||||
append: chunkAppend,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to write file chunk');
|
||||
}
|
||||
|
||||
currentOffset = chunkEnd;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read text from a file with specified encoding.
|
||||
* @param {string} path - Path to the file to read
|
||||
* @param {BufferEncoding} [encoding='utf8'] - Text encoding to use
|
||||
* @returns {Promise<string>} The decoded text content of the file
|
||||
*/
|
||||
async readText(path: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
|
||||
const contentBytes = await this.readBytes(path);
|
||||
return contentBytes.toString(encoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write text to a file with specified encoding.
|
||||
* @param {string} path - Path to the file to write
|
||||
* @param {string} content - Text content to write
|
||||
* @param {BufferEncoding} [encoding='utf8'] - Text encoding to use
|
||||
* @param {boolean} [append=false] - Whether to append to the file instead of overwriting
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async writeText(
|
||||
path: string,
|
||||
content: string,
|
||||
encoding: BufferEncoding = 'utf8',
|
||||
append: boolean = false
|
||||
): Promise<void> {
|
||||
const contentBytes = Buffer.from(content, encoding);
|
||||
await this.writeBytes(path, contentBytes, append);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read bytes from a file, with optional offset and length.
|
||||
* @param {string} path - Path to the file
|
||||
* @param {number} [offset=0] - Starting byte offset
|
||||
* @param {number} [length] - Number of bytes to read (reads entire file if not specified)
|
||||
* @returns {Promise<Buffer>} File content as Buffer
|
||||
* @throws {Error} If unable to read file
|
||||
*/
|
||||
async readBytes(path: string, offset: number = 0, length?: number): Promise<Buffer> {
|
||||
// For large files, use chunked reading
|
||||
if (length === undefined) {
|
||||
// Get file size first to determine if we need chunking
|
||||
const fileSize = await this.getFileSize(path);
|
||||
// If file is larger than 5MB, read in chunks
|
||||
if (fileSize > 5 * 1024 * 1024) {
|
||||
const readLength = offset > 0 ? fileSize - offset : fileSize;
|
||||
return await this.readBytesChunked(path, offset, readLength);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.sendCommand('read_bytes', {
|
||||
path,
|
||||
offset,
|
||||
length,
|
||||
});
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to read file');
|
||||
}
|
||||
return Buffer.from(response.content_b64 as string, 'base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write bytes to a file.
|
||||
* @param {string} path - Path to the file
|
||||
* @param {Buffer} content - Content to write as Buffer
|
||||
* @param {boolean} [append=false] - Whether to append to existing file
|
||||
* @returns {Promise<void>}
|
||||
* @throws {Error} If unable to write file
|
||||
*/
|
||||
async writeBytes(path: string, content: Buffer, append: boolean = false): Promise<void> {
|
||||
// For large files, use chunked writing
|
||||
if (content.length > 5 * 1024 * 1024) {
|
||||
// 5MB threshold
|
||||
await this.writeBytesChunked(path, content, append);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.sendCommand('write_bytes', {
|
||||
path,
|
||||
content_b64: content.toString('base64'),
|
||||
append,
|
||||
});
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to write file');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file at the specified path.
|
||||
* @param {string} path - Path to the file to delete
|
||||
* @returns {Promise<void>}
|
||||
* @throws {Error} If unable to delete file
|
||||
*/
|
||||
async deleteFile(path: string): Promise<void> {
|
||||
const response = await this.sendCommand('delete_file', { path });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to delete file');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a directory at the specified path.
|
||||
* @param {string} path - Path where to create the directory
|
||||
* @returns {Promise<void>}
|
||||
* @throws {Error} If unable to create directory
|
||||
*/
|
||||
async createDir(path: string): Promise<void> {
|
||||
const response = await this.sendCommand('create_dir', { path });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to create directory');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a directory at the specified path.
|
||||
* @param {string} path - Path to the directory to delete
|
||||
* @returns {Promise<void>}
|
||||
* @throws {Error} If unable to delete directory
|
||||
*/
|
||||
async deleteDir(path: string): Promise<void> {
|
||||
const response = await this.sendCommand('delete_dir', { path });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to delete directory');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a shell command and return stdout and stderr.
|
||||
* @param {string} command - Command to execute
|
||||
* @returns {Promise<[string, string]>} Tuple of [stdout, stderr]
|
||||
* @throws {Error} If command execution fails
|
||||
*/
|
||||
async runCommand(command: string): Promise<[string, string]> {
|
||||
const response = await this.sendCommand('run_command', { command });
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to run command');
|
||||
}
|
||||
return [(response.stdout as string) || '', (response.stderr as string) || ''];
|
||||
}
|
||||
|
||||
// Accessibility Actions
|
||||
/**
|
||||
* Get the accessibility tree of the current screen.
|
||||
* @returns {Promise<AccessibilityNode>} Root accessibility node
|
||||
* @throws {Error} If unable to get accessibility tree
|
||||
*/
|
||||
async getAccessibilityTree(): Promise<AccessibilityNode> {
|
||||
const response = await this.sendCommand('get_accessibility_tree');
|
||||
if (!response.success) {
|
||||
throw new Error((response.error as string) || 'Failed to get accessibility tree');
|
||||
}
|
||||
return response as unknown as AccessibilityNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert coordinates to screen coordinates.
|
||||
* @param {number} x - X coordinate to convert
|
||||
* @param {number} y - Y coordinate to convert
|
||||
* @returns {Promise<[number, number]>} Converted screen coordinates as [x, y]
|
||||
* @throws {Error} If coordinate conversion fails
|
||||
*/
|
||||
async toScreenCoordinates(x: number, y: number): Promise<[number, number]> {
|
||||
const response = await this.sendCommand('to_screen_coordinates', { x, y });
|
||||
if (!response.success || !response.coordinates) {
|
||||
throw new Error('Failed to convert to screen coordinates');
|
||||
}
|
||||
return response.coordinates as [number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert coordinates to screenshot coordinates.
|
||||
* @param {number} x - X coordinate to convert
|
||||
* @param {number} y - Y coordinate to convert
|
||||
* @returns {Promise<[number, number]>} Converted screenshot coordinates as [x, y]
|
||||
* @throws {Error} If coordinate conversion fails
|
||||
*/
|
||||
async toScreenshotCoordinates(x: number, y: number): Promise<[number, number]> {
|
||||
const response = await this.sendCommand('to_screenshot_coordinates', {
|
||||
x,
|
||||
y,
|
||||
});
|
||||
if (!response.success || !response.coordinates) {
|
||||
throw new Error('Failed to convert to screenshot coordinates');
|
||||
}
|
||||
return response.coordinates as [number, number];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Windows computer interface implementation.
|
||||
*/
|
||||
|
||||
import { MacOSComputerInterface } from './macos';
|
||||
|
||||
/**
|
||||
* Windows interface implementation.
|
||||
* Since the cloud provider uses the same WebSocket protocol for all OS types,
|
||||
* we can reuse the macOS implementation.
|
||||
*/
|
||||
export class WindowsComputerInterface extends MacOSComputerInterface {
|
||||
// Windows uses the same WebSocket interface as macOS for cloud provider
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export enum OSType {
|
||||
MACOS = 'macos',
|
||||
WINDOWS = 'windows',
|
||||
LINUX = 'linux',
|
||||
}
|
||||
|
||||
export interface ScreenSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Computer } from '../../src';
|
||||
import { OSType } from '../../src/types';
|
||||
|
||||
describe('Computer Cloud', () => {
|
||||
it('Should create computer instance', () => {
|
||||
const cloud = new Computer({
|
||||
apiKey: 'asdf',
|
||||
name: 's-linux-1234',
|
||||
osType: OSType.LINUX,
|
||||
});
|
||||
expect(cloud).toBeInstanceOf(Computer);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { InterfaceFactory } from '../../src/interface/factory.ts';
|
||||
import { LinuxComputerInterface } from '../../src/interface/linux.ts';
|
||||
import { MacOSComputerInterface } from '../../src/interface/macos.ts';
|
||||
import { WindowsComputerInterface } from '../../src/interface/windows.ts';
|
||||
import { OSType } from '../../src/types.ts';
|
||||
|
||||
describe('InterfaceFactory', () => {
|
||||
const testParams = {
|
||||
ipAddress: '192.168.1.100',
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
apiKey: 'test-api-key',
|
||||
vmName: 'test-vm',
|
||||
};
|
||||
|
||||
describe('createInterfaceForOS', () => {
|
||||
it('should create MacOSComputerInterface for macOS', () => {
|
||||
const interface_ = InterfaceFactory.createInterfaceForOS(
|
||||
OSType.MACOS,
|
||||
testParams.ipAddress,
|
||||
testParams.apiKey,
|
||||
testParams.vmName
|
||||
);
|
||||
|
||||
expect(interface_).toBeInstanceOf(MacOSComputerInterface);
|
||||
});
|
||||
|
||||
it('should create LinuxComputerInterface for Linux', () => {
|
||||
const interface_ = InterfaceFactory.createInterfaceForOS(
|
||||
OSType.LINUX,
|
||||
testParams.ipAddress,
|
||||
testParams.apiKey,
|
||||
testParams.vmName
|
||||
);
|
||||
|
||||
expect(interface_).toBeInstanceOf(LinuxComputerInterface);
|
||||
});
|
||||
|
||||
it('should create WindowsComputerInterface for Windows', () => {
|
||||
const interface_ = InterfaceFactory.createInterfaceForOS(
|
||||
OSType.WINDOWS,
|
||||
testParams.ipAddress,
|
||||
testParams.apiKey,
|
||||
testParams.vmName
|
||||
);
|
||||
|
||||
expect(interface_).toBeInstanceOf(WindowsComputerInterface);
|
||||
});
|
||||
|
||||
it('should throw error for unsupported OS type', () => {
|
||||
expect(() => {
|
||||
InterfaceFactory.createInterfaceForOS(
|
||||
'unsupported' as OSType,
|
||||
testParams.ipAddress,
|
||||
testParams.apiKey,
|
||||
testParams.vmName
|
||||
);
|
||||
}).toThrow('Unsupported OS type: unsupported');
|
||||
});
|
||||
|
||||
it('should create interface without API key and VM name', () => {
|
||||
const interface_ = InterfaceFactory.createInterfaceForOS(OSType.MACOS, testParams.ipAddress);
|
||||
|
||||
expect(interface_).toBeInstanceOf(MacOSComputerInterface);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as InterfaceExports from '../../src/interface/index.ts';
|
||||
|
||||
describe('Interface Module Exports', () => {
|
||||
it('should export InterfaceFactory', () => {
|
||||
expect(InterfaceExports.InterfaceFactory).toBeDefined();
|
||||
expect(InterfaceExports.InterfaceFactory.createInterfaceForOS).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export BaseComputerInterface', () => {
|
||||
expect(InterfaceExports.BaseComputerInterface).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export MacOSComputerInterface', () => {
|
||||
expect(InterfaceExports.MacOSComputerInterface).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export LinuxComputerInterface', () => {
|
||||
expect(InterfaceExports.LinuxComputerInterface).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export WindowsComputerInterface', () => {
|
||||
expect(InterfaceExports.WindowsComputerInterface).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export all expected interfaces', () => {
|
||||
const expectedExports = [
|
||||
'InterfaceFactory',
|
||||
'BaseComputerInterface',
|
||||
'MacOSComputerInterface',
|
||||
'LinuxComputerInterface',
|
||||
'WindowsComputerInterface',
|
||||
];
|
||||
|
||||
const actualExports = Object.keys(InterfaceExports);
|
||||
for (const exportName of expectedExports) {
|
||||
expect(actualExports).toContain(exportName);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LinuxComputerInterface } from '../../src/interface/linux.ts';
|
||||
import { MacOSComputerInterface } from '../../src/interface/macos.ts';
|
||||
|
||||
describe('LinuxComputerInterface', () => {
|
||||
const testParams = {
|
||||
ipAddress: 'test.cua.com', // TEST-NET-1 address (RFC 5737) - guaranteed not to be routable
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
apiKey: 'test-api-key',
|
||||
vmName: 'test-vm',
|
||||
};
|
||||
|
||||
describe('Inheritance', () => {
|
||||
it('should extend MacOSComputerInterface', () => {
|
||||
const linuxInterface = new LinuxComputerInterface(
|
||||
testParams.ipAddress,
|
||||
testParams.username,
|
||||
testParams.password,
|
||||
testParams.apiKey,
|
||||
testParams.vmName
|
||||
);
|
||||
|
||||
expect(linuxInterface).toBeInstanceOf(MacOSComputerInterface);
|
||||
expect(linuxInterface).toBeInstanceOf(LinuxComputerInterface);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,940 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { MacOSComputerInterface } from '../../src/interface/macos.ts';
|
||||
|
||||
describe('MacOSComputerInterface', () => {
|
||||
// Define test parameters
|
||||
const testParams = {
|
||||
ipAddress: 'localhost',
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
// apiKey: "test-api-key", No API Key for local testing
|
||||
vmName: 'test-vm',
|
||||
};
|
||||
|
||||
// WebSocket server mock
|
||||
let wss: WebSocketServer;
|
||||
let serverPort: number;
|
||||
let connectedClients: WebSocket[] = [];
|
||||
|
||||
// Track received messages for verification
|
||||
interface ReceivedMessage {
|
||||
action: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
let receivedMessages: ReceivedMessage[] = [];
|
||||
|
||||
// Set up WebSocket server before all tests
|
||||
beforeEach(async () => {
|
||||
receivedMessages = [];
|
||||
connectedClients = [];
|
||||
|
||||
// Create WebSocket server on a random available port
|
||||
wss = new WebSocketServer({ port: 0 });
|
||||
serverPort = (wss.address() as { port: number }).port;
|
||||
|
||||
// Update test params with the actual server address
|
||||
testParams.ipAddress = `localhost:${serverPort}`;
|
||||
|
||||
// Handle WebSocket connections
|
||||
wss.on('connection', (ws) => {
|
||||
connectedClients.push(ws);
|
||||
|
||||
// Handle incoming messages
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
receivedMessages.push(message);
|
||||
|
||||
// Send appropriate responses based on action
|
||||
switch (message.command) {
|
||||
case 'screenshot':
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
image_data: Buffer.from('fake-screenshot-data').toString('base64'),
|
||||
success: true,
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'get_screen_size':
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
size: { width: 1920, height: 1080 },
|
||||
success: true,
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'get_cursor_position':
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
position: { x: 100, y: 200 },
|
||||
success: true,
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'copy_to_clipboard':
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
content: 'clipboard content',
|
||||
success: true,
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'file_exists':
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
exists: true,
|
||||
success: true,
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'directory_exists':
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
exists: true,
|
||||
success: true,
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'list_dir':
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
files: ['file1.txt', 'file2.txt'],
|
||||
success: true,
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'read_text':
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
content: 'file content',
|
||||
success: true,
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'read_bytes':
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
content_b64: Buffer.from(
|
||||
message.params?.path === '/path/to/file.txt' ? 'file content' : 'binary content'
|
||||
).toString('base64'),
|
||||
success: true,
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'run_command':
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
stdout: 'command output',
|
||||
stderr: '',
|
||||
success: true,
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'get_accessibility_tree':
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
role: 'window',
|
||||
title: 'Test Window',
|
||||
bounds: { x: 0, y: 0, width: 1920, height: 1080 },
|
||||
children: [],
|
||||
success: true,
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'to_screen_coordinates':
|
||||
case 'to_screenshot_coordinates':
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
coordinates: [message.params?.x || 0, message.params?.y || 0],
|
||||
success: true,
|
||||
})
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// For all other actions, just send success
|
||||
ws.send(JSON.stringify({ success: true }));
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
ws.send(JSON.stringify({ error: (error as Error).message }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up WebSocket server after each test
|
||||
afterEach(async () => {
|
||||
// Close all connected clients
|
||||
for (const client of connectedClients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Close the server
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection Management', () => {
|
||||
it('should connect with proper authentication headers', async () => {
|
||||
const macosInterface = new MacOSComputerInterface(
|
||||
testParams.ipAddress,
|
||||
testParams.username,
|
||||
testParams.password,
|
||||
undefined,
|
||||
testParams.vmName
|
||||
);
|
||||
|
||||
await macosInterface.connect();
|
||||
|
||||
// Verify the interface is connected
|
||||
expect(macosInterface.isConnected()).toBe(true);
|
||||
expect(connectedClients.length).toBe(1);
|
||||
|
||||
await macosInterface.disconnect();
|
||||
});
|
||||
|
||||
it('should handle connection without API key', async () => {
|
||||
// Create a separate server that doesn't check auth
|
||||
const noAuthWss = new WebSocketServer({ port: 0 });
|
||||
const noAuthPort = (noAuthWss.address() as { port: number }).port;
|
||||
|
||||
noAuthWss.on('connection', (ws) => {
|
||||
ws.on('message', () => {
|
||||
ws.send(JSON.stringify({ success: true }));
|
||||
});
|
||||
});
|
||||
|
||||
const macosInterface = new MacOSComputerInterface(
|
||||
`localhost:${noAuthPort}`,
|
||||
testParams.username,
|
||||
testParams.password,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
await macosInterface.connect();
|
||||
expect(macosInterface.isConnected()).toBe(true);
|
||||
|
||||
await macosInterface.disconnect();
|
||||
await new Promise<void>((resolve) => {
|
||||
noAuthWss.close(() => resolve());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mouse Actions', () => {
|
||||
let macosInterface: MacOSComputerInterface;
|
||||
|
||||
beforeEach(async () => {
|
||||
macosInterface = new MacOSComputerInterface(
|
||||
testParams.ipAddress,
|
||||
testParams.username,
|
||||
testParams.password,
|
||||
undefined,
|
||||
testParams.vmName
|
||||
);
|
||||
await macosInterface.connect();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (macosInterface) {
|
||||
await macosInterface.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it('should send mouse_down command', async () => {
|
||||
await macosInterface.mouseDown(100, 200, 'left');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'mouse_down',
|
||||
params: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
button: 'left',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send mouse_up command', async () => {
|
||||
await macosInterface.mouseUp(100, 200, 'right');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'mouse_up',
|
||||
params: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
button: 'right',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send left_click command', async () => {
|
||||
await macosInterface.leftClick(150, 250);
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'left_click',
|
||||
params: {
|
||||
x: 150,
|
||||
y: 250,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send right_click command', async () => {
|
||||
await macosInterface.rightClick(200, 300);
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'right_click',
|
||||
params: {
|
||||
x: 200,
|
||||
y: 300,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send double_click command', async () => {
|
||||
await macosInterface.doubleClick(250, 350);
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'double_click',
|
||||
params: {
|
||||
x: 250,
|
||||
y: 350,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send move_cursor command', async () => {
|
||||
await macosInterface.moveCursor(300, 400);
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'move_cursor',
|
||||
params: {
|
||||
x: 300,
|
||||
y: 400,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send drag_to command', async () => {
|
||||
await macosInterface.dragTo(400, 500, 'left', 1.5);
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'drag_to',
|
||||
params: {
|
||||
x: 400,
|
||||
y: 500,
|
||||
button: 'left',
|
||||
duration: 1.5,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send drag command with path', async () => {
|
||||
const path: Array<[number, number]> = [
|
||||
[100, 100],
|
||||
[200, 200],
|
||||
[300, 300],
|
||||
];
|
||||
await macosInterface.drag(path, 'middle', 2.0);
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'drag',
|
||||
params: {
|
||||
path: path,
|
||||
button: 'middle',
|
||||
duration: 2.0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Actions', () => {
|
||||
let macosInterface: MacOSComputerInterface;
|
||||
|
||||
beforeEach(async () => {
|
||||
macosInterface = new MacOSComputerInterface(
|
||||
testParams.ipAddress,
|
||||
testParams.username,
|
||||
testParams.password,
|
||||
undefined,
|
||||
testParams.vmName
|
||||
);
|
||||
await macosInterface.connect();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (macosInterface) {
|
||||
await macosInterface.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it('should send key_down command', async () => {
|
||||
await macosInterface.keyDown('a');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'key_down',
|
||||
params: {
|
||||
key: 'a',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send key_up command', async () => {
|
||||
await macosInterface.keyUp('b');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'key_up',
|
||||
params: {
|
||||
key: 'b',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send type_text command', async () => {
|
||||
await macosInterface.typeText('Hello, World!');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'type_text',
|
||||
params: {
|
||||
text: 'Hello, World!',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send press_key command', async () => {
|
||||
await macosInterface.pressKey('enter');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'press_key',
|
||||
params: {
|
||||
key: 'enter',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send hotkey command', async () => {
|
||||
await macosInterface.hotkey('cmd', 'c');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'hotkey',
|
||||
params: {
|
||||
keys: ['cmd', 'c'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scrolling Actions', () => {
|
||||
let macosInterface: MacOSComputerInterface;
|
||||
|
||||
beforeEach(async () => {
|
||||
macosInterface = new MacOSComputerInterface(
|
||||
testParams.ipAddress,
|
||||
testParams.username,
|
||||
testParams.password,
|
||||
undefined,
|
||||
testParams.vmName
|
||||
);
|
||||
await macosInterface.connect();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (macosInterface) {
|
||||
await macosInterface.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it('should send scroll command', async () => {
|
||||
await macosInterface.scroll(10, -5);
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'scroll',
|
||||
params: {
|
||||
x: 10,
|
||||
y: -5,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send scroll_down command', async () => {
|
||||
await macosInterface.scrollDown(3);
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'scroll_down',
|
||||
params: {
|
||||
clicks: 3,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send scroll_up command', async () => {
|
||||
await macosInterface.scrollUp(2);
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'scroll_up',
|
||||
params: {
|
||||
clicks: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Screen Actions', () => {
|
||||
let macosInterface: MacOSComputerInterface;
|
||||
|
||||
beforeEach(async () => {
|
||||
macosInterface = new MacOSComputerInterface(
|
||||
testParams.ipAddress,
|
||||
testParams.username,
|
||||
testParams.password,
|
||||
undefined,
|
||||
testParams.vmName
|
||||
);
|
||||
await macosInterface.connect();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (macosInterface) {
|
||||
await macosInterface.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it('should get screenshot', async () => {
|
||||
const screenshot = await macosInterface.screenshot();
|
||||
|
||||
expect(screenshot).toBeInstanceOf(Buffer);
|
||||
expect(screenshot.toString()).toBe('fake-screenshot-data');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'screenshot',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should get screen size', async () => {
|
||||
const size = await macosInterface.getScreenSize();
|
||||
|
||||
expect(size).toEqual({ width: 1920, height: 1080 });
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'get_screen_size',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should get cursor position', async () => {
|
||||
const position = await macosInterface.getCursorPosition();
|
||||
|
||||
expect(position).toEqual({ x: 100, y: 200 });
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'get_cursor_position',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Clipboard Actions', () => {
|
||||
let macosInterface: MacOSComputerInterface;
|
||||
|
||||
beforeEach(async () => {
|
||||
macosInterface = new MacOSComputerInterface(
|
||||
testParams.ipAddress,
|
||||
testParams.username,
|
||||
testParams.password,
|
||||
undefined,
|
||||
testParams.vmName
|
||||
);
|
||||
await macosInterface.connect();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (macosInterface) {
|
||||
await macosInterface.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it('should copy to clipboard', async () => {
|
||||
const text = await macosInterface.copyToClipboard();
|
||||
|
||||
expect(text).toBe('clipboard content');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'copy_to_clipboard',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should set clipboard', async () => {
|
||||
await macosInterface.setClipboard('new clipboard text');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'set_clipboard',
|
||||
params: {
|
||||
text: 'new clipboard text',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('File System Actions', () => {
|
||||
let macosInterface: MacOSComputerInterface;
|
||||
|
||||
beforeEach(async () => {
|
||||
macosInterface = new MacOSComputerInterface(
|
||||
testParams.ipAddress,
|
||||
testParams.username,
|
||||
testParams.password,
|
||||
undefined,
|
||||
testParams.vmName
|
||||
);
|
||||
await macosInterface.connect();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (macosInterface) {
|
||||
await macosInterface.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it('should check file exists', async () => {
|
||||
const exists = await macosInterface.fileExists('/path/to/file');
|
||||
|
||||
expect(exists).toBe(true);
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'file_exists',
|
||||
params: {
|
||||
path: '/path/to/file',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should check directory exists', async () => {
|
||||
const exists = await macosInterface.directoryExists('/path/to/dir');
|
||||
|
||||
expect(exists).toBe(true);
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'directory_exists',
|
||||
params: {
|
||||
path: '/path/to/dir',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should list directory', async () => {
|
||||
const files = await macosInterface.listDir('/path/to/dir');
|
||||
|
||||
expect(files).toEqual(['file1.txt', 'file2.txt']);
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'list_dir',
|
||||
params: {
|
||||
path: '/path/to/dir',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should read text file', async () => {
|
||||
const content = await macosInterface.readText('/path/to/file.txt');
|
||||
|
||||
expect(content).toBe('file content');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'read_bytes',
|
||||
params: {
|
||||
path: '/path/to/file.txt',
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should write text file', async () => {
|
||||
await macosInterface.writeText('/path/to/file.txt', 'new content');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'write_bytes',
|
||||
params: {
|
||||
path: '/path/to/file.txt',
|
||||
content_b64: Buffer.from('new content').toString('base64'),
|
||||
append: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should read binary file', async () => {
|
||||
const content = await macosInterface.readBytes('/path/to/file.bin');
|
||||
|
||||
expect(content).toBeInstanceOf(Buffer);
|
||||
expect(content.toString()).toBe('binary content');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'read_bytes',
|
||||
params: {
|
||||
path: '/path/to/file.bin',
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should write binary file', async () => {
|
||||
const buffer = Buffer.from('binary data');
|
||||
await macosInterface.writeBytes('/path/to/file.bin', buffer);
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'write_bytes',
|
||||
params: {
|
||||
path: '/path/to/file.bin',
|
||||
content_b64: buffer.toString('base64'),
|
||||
append: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete file', async () => {
|
||||
await macosInterface.deleteFile('/path/to/file');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'delete_file',
|
||||
params: {
|
||||
path: '/path/to/file',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should create directory', async () => {
|
||||
await macosInterface.createDir('/path/to/new/dir');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'create_dir',
|
||||
params: {
|
||||
path: '/path/to/new/dir',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete directory', async () => {
|
||||
await macosInterface.deleteDir('/path/to/dir');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'delete_dir',
|
||||
params: {
|
||||
path: '/path/to/dir',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should run command', async () => {
|
||||
const [stdout, stderr] = await macosInterface.runCommand('ls -la');
|
||||
|
||||
expect(stdout).toBe('command output');
|
||||
expect(stderr).toBe('');
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'run_command',
|
||||
params: {
|
||||
command: 'ls -la',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility Actions', () => {
|
||||
let macosInterface: MacOSComputerInterface;
|
||||
|
||||
beforeEach(async () => {
|
||||
macosInterface = new MacOSComputerInterface(
|
||||
testParams.ipAddress,
|
||||
testParams.username,
|
||||
testParams.password,
|
||||
undefined,
|
||||
testParams.vmName
|
||||
);
|
||||
await macosInterface.connect();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (macosInterface) {
|
||||
await macosInterface.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it('should get accessibility tree', async () => {
|
||||
const tree = await macosInterface.getAccessibilityTree();
|
||||
|
||||
expect(tree).toEqual({
|
||||
role: 'window',
|
||||
title: 'Test Window',
|
||||
bounds: { x: 0, y: 0, width: 1920, height: 1080 },
|
||||
children: [],
|
||||
success: true,
|
||||
});
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'get_accessibility_tree',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert to screen coordinates', async () => {
|
||||
const [x, y] = await macosInterface.toScreenCoordinates(100, 200);
|
||||
|
||||
expect(x).toBe(100);
|
||||
expect(y).toBe(200);
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'to_screen_coordinates',
|
||||
params: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert to screenshot coordinates', async () => {
|
||||
const [x, y] = await macosInterface.toScreenshotCoordinates(300, 400);
|
||||
|
||||
expect(x).toBe(300);
|
||||
expect(y).toBe(400);
|
||||
|
||||
const lastMessage = receivedMessages[receivedMessages.length - 1];
|
||||
expect(lastMessage).toEqual({
|
||||
command: 'to_screenshot_coordinates',
|
||||
params: {
|
||||
x: 300,
|
||||
y: 400,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle WebSocket connection errors', async () => {
|
||||
// Use a valid but unreachable IP to avoid DNS errors
|
||||
const macosInterface = new MacOSComputerInterface(
|
||||
'localhost:9999',
|
||||
testParams.username,
|
||||
testParams.password,
|
||||
undefined,
|
||||
testParams.vmName
|
||||
);
|
||||
|
||||
// Connection should fail
|
||||
await expect(macosInterface.connect()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle command errors', async () => {
|
||||
// Create a server that returns errors
|
||||
const errorWss = new WebSocketServer({ port: 0 });
|
||||
const errorPort = (errorWss.address() as { port: number }).port;
|
||||
|
||||
errorWss.on('connection', (ws) => {
|
||||
ws.on('message', () => {
|
||||
ws.send(JSON.stringify({ error: 'Command failed', success: false }));
|
||||
});
|
||||
});
|
||||
|
||||
const macosInterface = new MacOSComputerInterface(
|
||||
`localhost:${errorPort}`,
|
||||
testParams.username,
|
||||
testParams.password,
|
||||
undefined,
|
||||
testParams.vmName
|
||||
);
|
||||
|
||||
await macosInterface.connect();
|
||||
|
||||
// Command should throw error
|
||||
await expect(macosInterface.leftClick(100, 100)).rejects.toThrow('Command failed');
|
||||
|
||||
await macosInterface.disconnect();
|
||||
await new Promise<void>((resolve) => {
|
||||
errorWss.close(() => resolve());
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle disconnection gracefully', async () => {
|
||||
const macosInterface = new MacOSComputerInterface(
|
||||
testParams.ipAddress,
|
||||
testParams.username,
|
||||
testParams.password,
|
||||
undefined,
|
||||
testParams.vmName
|
||||
);
|
||||
|
||||
await macosInterface.connect();
|
||||
expect(macosInterface.isConnected()).toBe(true);
|
||||
|
||||
// Disconnect
|
||||
macosInterface.disconnect();
|
||||
expect(macosInterface.isConnected()).toBe(false);
|
||||
|
||||
// Should reconnect automatically on next command
|
||||
await macosInterface.leftClick(100, 100);
|
||||
expect(macosInterface.isConnected()).toBe(true);
|
||||
|
||||
await macosInterface.disconnect();
|
||||
});
|
||||
|
||||
it('should handle force close', async () => {
|
||||
const macosInterface = new MacOSComputerInterface(
|
||||
testParams.ipAddress,
|
||||
testParams.username,
|
||||
testParams.password,
|
||||
undefined,
|
||||
testParams.vmName
|
||||
);
|
||||
|
||||
await macosInterface.connect();
|
||||
expect(macosInterface.isConnected()).toBe(true);
|
||||
|
||||
// Force close
|
||||
macosInterface.forceClose();
|
||||
expect(macosInterface.isConnected()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MacOSComputerInterface } from '../../src/interface/macos.ts';
|
||||
import { WindowsComputerInterface } from '../../src/interface/windows.ts';
|
||||
|
||||
describe('WindowsComputerInterface', () => {
|
||||
const testParams = {
|
||||
ipAddress: '192.0.2.1', // TEST-NET-1 address (RFC 5737) - guaranteed not to be routable
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
apiKey: 'test-api-key',
|
||||
vmName: 'test-vm',
|
||||
};
|
||||
|
||||
describe('Inheritance', () => {
|
||||
it('should extend MacOSComputerInterface', () => {
|
||||
const windowsInterface = new WindowsComputerInterface(
|
||||
testParams.ipAddress,
|
||||
testParams.username,
|
||||
testParams.password,
|
||||
testParams.apiKey,
|
||||
testParams.vmName
|
||||
);
|
||||
|
||||
expect(windowsInterface).toBeInstanceOf(MacOSComputerInterface);
|
||||
expect(windowsInterface).toBeInstanceOf(WindowsComputerInterface);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { afterAll, afterEach, beforeAll } from 'vitest';
|
||||
|
||||
beforeAll(() => {});
|
||||
|
||||
afterAll(() => {});
|
||||
|
||||
afterEach(() => {});
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": ["es2023"],
|
||||
"moduleDetection": "force",
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"],
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'));
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['./src/index.ts'],
|
||||
platform: 'node',
|
||||
dts: true,
|
||||
external: ['child_process', 'util'],
|
||||
define: {
|
||||
__CUA_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user