Implement interfaces, reorganize imports

This commit is contained in:
Morgan Dean
2025-06-18 13:14:48 -07:00
parent b3eeb0c9f5
commit 80c7c28799
21 changed files with 764 additions and 1363 deletions
+3 -1
View File
@@ -39,11 +39,13 @@
},
"dependencies": {
"pino": "^9.7.0",
"sharp": "^0.33.0"
"sharp": "^0.33.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/node": "^22.15.17",
"@types/ws": "^8.18.1",
"bumpp": "^10.1.0",
"happy-dom": "^17.4.7",
"tsdown": "^0.11.9",
+27
View File
@@ -14,6 +14,9 @@ importers:
sharp:
specifier: ^0.33.0
version: 0.33.5
ws:
specifier: ^8.18.0
version: 8.18.2
devDependencies:
'@biomejs/biome':
specifier: ^1.9.4
@@ -21,6 +24,9 @@ importers:
'@types/node':
specifier: ^22.15.17
version: 22.15.31
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
bumpp:
specifier: ^10.1.0
version: 10.1.1
@@ -595,6 +601,9 @@ packages:
'@types/node@22.15.31':
resolution: {integrity: sha512-jnVe5ULKl6tijxUhvQeNbQG/84fHfg+yMak02cT8QVhBx/F05rAVxCGBYYTh2EKz22D6JF5ktXuNwdx7b9iEGw==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@vitest/expect@3.2.3':
resolution: {integrity: sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==}
@@ -1124,6 +1133,18 @@ packages:
engines: {node: '>=8'}
hasBin: true
ws@8.18.2:
resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
yaml@2.8.0:
resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==}
engines: {node: '>= 14.6'}
@@ -1510,6 +1531,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/ws@8.18.1':
dependencies:
'@types/node': 22.15.31
'@vitest/expect@3.2.3':
dependencies:
'@types/chai': 5.2.2
@@ -2090,4 +2115,6 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
ws@8.18.2: {}
yaml@2.8.0: {}
@@ -1,36 +0,0 @@
import { OSType, VMProviderType } from "./types";
import type { BaseComputerConfig } from "./types";
/**
* Default configuration values for Computer
*/
export const DEFAULT_CONFIG: Partial<BaseComputerConfig> = {
name: "",
osType: OSType.MACOS,
vmProvider: VMProviderType.CLOUD,
display: "1024x768",
memory: "8GB",
cpu: 4,
image: "macos-sequoia-cua:latest",
sharedDirectories: [],
useHostComputerServer: false,
telemetryEnabled: true,
port: 7777,
noVNCPort: 8006,
host: "localhost",
ephemeral: false,
};
/**
* Apply default values to a computer configuration
* @param config Partial configuration
* @returns Complete configuration with defaults applied
*/
export function applyDefaults<T extends BaseComputerConfig>(
config: Partial<T>
): T {
return {
...DEFAULT_CONFIG,
...config,
} as T;
}
+1 -45
View File
@@ -1,45 +1 @@
import type { BaseComputer } from "./providers/base";
import { CloudComputer } from "./providers/cloud";
import { LumeComputer } from "./providers/lume";
import {
VMProviderType,
type BaseComputerConfig,
type CloudComputerConfig,
type LumeComputerConfig,
} from "./types";
import { applyDefaults } from "./defaults";
import pino from "pino";
export const logger = pino({ name: "computer" });
/**
* Factory class for creating the appropriate Computer instance
*/
export const Computer = {
/**
* Create a computer instance based on the provided configuration
* @param config The computer configuration
* @returns The appropriate computer instance based on the VM provider type
*/
create: (
config:
| Partial<BaseComputerConfig>
| Partial<CloudComputerConfig>
| Partial<LumeComputerConfig>
): BaseComputer => {
// Apply defaults to the configuration
const fullConfig = applyDefaults(config);
// Check the vmProvider property to determine which type of computer to create
switch (fullConfig.vmProvider) {
case VMProviderType.CLOUD:
return new CloudComputer(fullConfig as CloudComputerConfig);
case VMProviderType.LUME:
return new LumeComputer(fullConfig as LumeComputerConfig);
default:
throw new Error(
`Unsupported VM provider type: ${fullConfig.vmProvider}`
);
}
},
};
export { BaseComputer, CloudComputer, LumeComputer } from "./providers";
@@ -1,10 +1,8 @@
import { logger } from "../index";
import type {
BaseComputerConfig,
Display,
OSType,
VMProviderType,
} from "../types";
import type { OSType } from "../../types";
import type { BaseComputerConfig, Display, VMProviderType } from "../types";
import pino from "pino";
const logger = pino({ name: "computer-base" });
/**
* Base Computer class with shared functionality
@@ -12,12 +10,11 @@ import type {
export abstract class BaseComputer {
protected name: string;
protected osType: OSType;
protected vmProvider: VMProviderType;
protected vmProvider?: VMProviderType;
constructor(config: BaseComputerConfig) {
this.name = config.name;
this.osType = config.osType;
this.vmProvider = config.vmProvider;
}
/**
@@ -37,7 +34,7 @@ export abstract class BaseComputer {
/**
* Get the VM provider type
*/
getVMProviderType(): VMProviderType {
getVMProviderType(): VMProviderType | undefined {
return this.vmProvider;
}
@@ -1,31 +1,138 @@
import { BaseComputer } from "./base";
import type { CloudComputerConfig } from "../types";
import type { CloudComputerConfig, VMProviderType } from "../types";
import {
InterfaceFactory,
type BaseComputerInterface,
} from "../../interface/index";
import pino from "pino";
const logger = pino({ name: "cloud" });
const logger = pino({ name: "computer-cloud" });
/**
* Cloud-specific computer implementation
*/
export class CloudComputer extends BaseComputer {
private apiKey: string;
protected apiKey: string;
protected static vmProviderType: VMProviderType.CLOUD;
private interface?: BaseComputerInterface;
private initialized = false;
constructor(config: CloudComputerConfig) {
super(config);
this.apiKey = config.apiKey;
}
/**
* Cloud-specific method to deploy the computer
*/
async deploy(): Promise<void> {
logger.info(`Deploying cloud computer ${this.name}`);
// Cloud-specific implementation
get ip() {
return `${this.name}.containers.cloud.trycua.com`;
}
/**
* Cloud-specific method to get deployment status
* Initialize the cloud VM and interface
*/
async getDeploymentStatus(): Promise<string> {
return "running"; // Example implementation
async run(): Promise<void> {
if (this.initialized) {
logger.info("Computer already initialized, skipping initialization");
return;
}
logger.info("Starting cloud computer...");
try {
// For cloud provider, the VM is already running, we just need to connect
const ipAddress = this.ip;
logger.info(`Connecting to cloud VM at ${ipAddress}`);
// Create the interface with API key authentication
this.interface = InterfaceFactory.createInterfaceForOS(
this.osType,
ipAddress,
this.apiKey,
this.name
);
// Wait for the interface to be ready
logger.info("Waiting for interface to be ready...");
await this.interface.waitForReady();
this.initialized = true;
logger.info("Cloud computer ready");
} catch (error) {
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> {
logger.info("Stopping cloud computer...");
if (this.interface) {
this.interface.close();
this.interface = undefined;
}
this.initialized = false;
logger.info("Cloud computer stopped");
}
/**
* Get the computer interface
*/
getInterface(): BaseComputerInterface {
if (!this.interface) {
throw new Error("Computer not initialized. Call run() first.");
}
return this.interface;
}
/**
* Take a screenshot
*/
async screenshot(): Promise<Buffer> {
return this.getInterface().screenshot();
}
/**
* Click at coordinates
*/
async click(x?: number, y?: number): Promise<void> {
return this.getInterface().leftClick(x, y);
}
/**
* Type text
*/
async type(text: string): Promise<void> {
return this.getInterface().typeText(text);
}
/**
* Press a key
*/
async key(key: string): Promise<void> {
return this.getInterface().pressKey(key);
}
/**
* Press hotkey combination
*/
async hotkey(...keys: string[]): Promise<void> {
return this.getInterface().hotkey(...keys);
}
/**
* Run a command
*/
async runCommand(command: string): Promise<[string, string]> {
return this.getInterface().runCommand(command);
}
/**
* Disconnect from the cloud computer
*/
async disconnect(): Promise<void> {
await this.stop();
}
}
@@ -0,0 +1,3 @@
export * from "./base";
export * from "./cloud";
export * from "./lume";
@@ -3,9 +3,8 @@
* It serves as a reference for how a provider might be implemented but should not be used in production.
*/
import type { Display, LumeComputerConfig } from "../types";
import { BaseComputer } from "./base";
import { applyDefaults } from "../defaults";
import type { Display, LumeComputerConfig } from "../types.ts";
import { BaseComputer } from "./base.ts";
import {
lumeApiGet,
lumeApiRun,
@@ -14,10 +13,10 @@ import {
lumeApiDelete,
lumeApiUpdate,
type VMInfo,
} from "../../util/lume";
} from "../../util/lume.ts";
import pino from "pino";
const logger = pino({ name: "lume_computer" });
const logger = pino({ name: "computer-lume" });
/**
* Lume-specific computer implementation
@@ -34,15 +33,13 @@ export class LumeComputer extends BaseComputer {
constructor(config: LumeComputerConfig) {
super(config);
const defaultConfig = applyDefaults(config);
this.display = defaultConfig.display;
this.memory = defaultConfig.memory;
this.cpu = defaultConfig.cpu;
this.image = defaultConfig.image;
this.port = defaultConfig.port;
this.host = defaultConfig.host;
this.ephemeral = defaultConfig.ephemeral;
this.display = config.display ?? "1024x768";
this.memory = config.memory ?? "8GB";
this.cpu = config.cpu ?? 2;
this.image = config.image ?? "macos-sequoia-cua:latest";
this.port = config.port ?? 7777;
this.host = config.host ?? "localhost";
this.ephemeral = config.ephemeral ?? false;
}
/**
+9 -36
View File
@@ -1,9 +1,9 @@
import type { OSType, ScreenSize } from "../types";
/**
* Display configuration for the computer.
*/
export interface Display {
width: number;
height: number;
export interface Display extends ScreenSize {
scale_factor?: number;
}
@@ -22,13 +22,16 @@ export interface BaseComputerConfig {
* @default "macos"
*/
osType: OSType;
}
export interface CloudComputerConfig extends BaseComputerConfig {
/**
* The VM provider type to use (lume, cloud)
* @default VMProviderType.LUME
* Optional API key for cloud providers
*/
vmProvider: VMProviderType;
apiKey: string;
}
export interface LumeComputerConfig extends BaseComputerConfig {
/**
* The display configuration. Can be:
* - A Display object
@@ -108,37 +111,7 @@ export interface BaseComputerConfig {
experiments?: string[];
}
export interface CloudComputerConfig extends BaseComputerConfig {
/**
* Optional API key for cloud providers
*/
apiKey: string;
/**
* Size of the cloud VM
*/
size: "small" | "medium" | "large";
/**
* The Cloud VM provider type
*/
vmProvider: VMProviderType.CLOUD;
}
export interface LumeComputerConfig extends BaseComputerConfig {
/**
* The Lume VM provider type
*/
vmProvider: VMProviderType.LUME;
}
export enum VMProviderType {
CLOUD = "cloud",
LUME = "lume",
}
export enum OSType {
MACOS = "macos",
WINDOWS = "windows",
LINUX = "linux",
}
+3 -4
View File
@@ -1,5 +1,4 @@
// Export types
export * from "./computer/types";
// Expore classes
// Export classes
export * from "./computer";
//todo: figure out what types to export and how to do that
@@ -0,0 +1,259 @@
/**
* Base interface for computer control.
*/
import type { ScreenSize } from "../types";
import WebSocket from "ws";
import pino from "pino";
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 apiKey?: string;
protected vmName?: string;
protected ws?: WebSocket;
protected closed = false;
protected commandLock: Promise<unknown> = Promise.resolve();
protected logger = pino({ name: "interface-base" });
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;
}
/**
* Get the WebSocket URI for connection.
* Subclasses can override this to customize the URI.
*/
protected get wsUri(): string {
// Use secure WebSocket for cloud provider with API key
const protocol = this.apiKey ? "wss" : "ws";
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) {
// Wait a bit before retrying
this.logger.error(`Error connecting to websocket: ${error}`);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
throw new Error(`Interface not ready after ${timeout} seconds`);
}
/**
* Connect to the WebSocket server.
*/
protected async connect(): Promise<void> {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
return;
}
return new Promise((resolve, reject) => {
const headers: { [key: string]: string } = {};
if (this.apiKey && this.vmName) {
headers["X-API-Key"] = this.apiKey;
headers["X-VM-Name"] = this.vmName;
}
this.ws = new WebSocket(this.wsUri, { headers });
this.ws.on("open", () => {
resolve();
});
this.ws.on("error", (error: Error) => {
reject(error);
});
this.ws.on("close", () => {
if (!this.closed) {
// Attempt to reconnect
setTimeout(() => this.connect(), 1000);
}
});
});
}
/**
* Send a command to the WebSocket server.
*/
protected async sendCommand(command: {
action: string;
[key: string]: unknown;
}): Promise<{ [key: string]: unknown }> {
// 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);
this.ws!.send(JSON.stringify(command));
}
);
};
// Add this command to the lock chain
this.commandLock = this.commandLock.then(() =>
executeCommand().then(resolve, reject)
);
}
);
return commandPromise;
}
/**
* Close the interface connection.
*/
close(): void {
this.closed = true;
if (this.ws) {
this.ws.close();
this.ws = undefined;
}
}
/**
* Force close the interface connection.
* By default, this just calls close(), but subclasses can override
* to provide more forceful cleanup.
*/
forceClose(): void {
this.close();
}
// 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>;
// 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,57 @@
/**
* Factory for creating computer interfaces.
*/
import type { BaseComputerInterface } from "./base";
import { MacOSComputerInterface } from "./macos";
import { LinuxComputerInterface } from "./linux";
import { WindowsComputerInterface } from "./windows";
import type { OSType } from "../types";
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,207 @@
/**
* macOS computer interface implementation.
*/
import { BaseComputerInterface } from "./base";
import type { MouseButton, CursorPosition, AccessibilityNode } from "./base";
import type { ScreenSize } from "../types";
export class MacOSComputerInterface extends BaseComputerInterface {
// Mouse Actions
async mouseDown(
x?: number,
y?: number,
button: MouseButton = "left"
): Promise<void> {
await this.sendCommand({ action: "mouse_down", x, y, button });
}
async mouseUp(
x?: number,
y?: number,
button: MouseButton = "left"
): Promise<void> {
await this.sendCommand({ action: "mouse_up", x, y, button });
}
async leftClick(x?: number, y?: number): Promise<void> {
await this.sendCommand({ action: "left_click", x, y });
}
async rightClick(x?: number, y?: number): Promise<void> {
await this.sendCommand({ action: "right_click", x, y });
}
async doubleClick(x?: number, y?: number): Promise<void> {
await this.sendCommand({ action: "double_click", x, y });
}
async moveCursor(x: number, y: number): Promise<void> {
await this.sendCommand({ action: "move_cursor", x, y });
}
async dragTo(
x: number,
y: number,
button: MouseButton = "left",
duration = 0.5
): Promise<void> {
await this.sendCommand({ action: "drag_to", x, y, button, duration });
}
async drag(
path: Array<[number, number]>,
button: MouseButton = "left",
duration = 0.5
): Promise<void> {
await this.sendCommand({ action: "drag", path, button, duration });
}
// Keyboard Actions
async keyDown(key: string): Promise<void> {
await this.sendCommand({ action: "key_down", key });
}
async keyUp(key: string): Promise<void> {
await this.sendCommand({ action: "key_up", key });
}
async typeText(text: string): Promise<void> {
await this.sendCommand({ action: "type_text", text });
}
async pressKey(key: string): Promise<void> {
await this.sendCommand({ action: "press_key", key });
}
async hotkey(...keys: string[]): Promise<void> {
await this.sendCommand({ action: "hotkey", keys });
}
// Scrolling Actions
async scroll(x: number, y: number): Promise<void> {
await this.sendCommand({ action: "scroll", x, y });
}
async scrollDown(clicks = 1): Promise<void> {
await this.sendCommand({ action: "scroll_down", clicks });
}
async scrollUp(clicks = 1): Promise<void> {
await this.sendCommand({ action: "scroll_up", clicks });
}
// Screen Actions
async screenshot(): Promise<Buffer> {
const response = await this.sendCommand({ action: "screenshot" });
return Buffer.from(response.data as string, "base64");
}
async getScreenSize(): Promise<ScreenSize> {
const response = await this.sendCommand({ action: "get_screen_size" });
return response.data as ScreenSize;
}
async getCursorPosition(): Promise<CursorPosition> {
const response = await this.sendCommand({ action: "get_cursor_position" });
return response.data as CursorPosition;
}
// Clipboard Actions
async copyToClipboard(): Promise<string> {
const response = await this.sendCommand({ action: "copy_to_clipboard" });
return response.data as string;
}
async setClipboard(text: string): Promise<void> {
await this.sendCommand({ action: "set_clipboard", text });
}
// File System Actions
async fileExists(path: string): Promise<boolean> {
const response = await this.sendCommand({ action: "file_exists", path });
return response.data as boolean;
}
async directoryExists(path: string): Promise<boolean> {
const response = await this.sendCommand({
action: "directory_exists",
path,
});
return response.data as boolean;
}
async listDir(path: string): Promise<string[]> {
const response = await this.sendCommand({ action: "list_dir", path });
return response.data as string[];
}
async readText(path: string): Promise<string> {
const response = await this.sendCommand({ action: "read_text", path });
return response.data as string;
}
async writeText(path: string, content: string): Promise<void> {
await this.sendCommand({ action: "write_text", path, content });
}
async readBytes(path: string): Promise<Buffer> {
const response = await this.sendCommand({ action: "read_bytes", path });
return Buffer.from(response.data as string, "base64");
}
async writeBytes(path: string, content: Buffer): Promise<void> {
await this.sendCommand({
action: "write_bytes",
path,
content: content.toString("base64"),
});
}
async deleteFile(path: string): Promise<void> {
await this.sendCommand({ action: "delete_file", path });
}
async createDir(path: string): Promise<void> {
await this.sendCommand({ action: "create_dir", path });
}
async deleteDir(path: string): Promise<void> {
await this.sendCommand({ action: "delete_dir", path });
}
async runCommand(command: string): Promise<[string, string]> {
const response = await this.sendCommand({ action: "run_command", command });
const data = response.data as { stdout: string; stderr: string };
return [data.stdout, data.stderr];
}
// Accessibility Actions
async getAccessibilityTree(): Promise<AccessibilityNode> {
const response = await this.sendCommand({
action: "get_accessibility_tree",
});
return response.data as AccessibilityNode;
}
async toScreenCoordinates(x: number, y: number): Promise<[number, number]> {
const response = await this.sendCommand({
action: "to_screen_coordinates",
x,
y,
});
return response.data as [number, number];
}
async toScreenshotCoordinates(
x: number,
y: number
): Promise<[number, number]> {
const response = await this.sendCommand({
action: "to_screenshot_coordinates",
x,
y,
});
return response.data 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
}
+10
View File
@@ -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 { OSType } from "../../src/types";
import { CloudComputer } from "../../src/computer/providers/cloud";
describe("Computer Cloud", () => {
it("Should create computer instance", () => {
const cloud = new CloudComputer({
apiKey: "asdf",
name: "s-linux-1234",
osType: OSType.LINUX,
});
expect(cloud).toBeInstanceOf(CloudComputer);
});
});
@@ -1,594 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { LumeComputer } from "../../src/computer/providers/lume";
import { VMProviderType, OSType } from "../../src/computer/types";
import type { LumeComputerConfig, Display } from "../../src/computer/types";
import type { VMInfo } from "../../src/util/lume";
import * as lumeApi from "../../src/util/lume";
// Mock the lume API module
vi.mock("../../src/util/lume", () => ({
lumeApiGet: vi.fn(),
lumeApiRun: vi.fn(),
lumeApiStop: vi.fn(),
lumeApiUpdate: vi.fn(),
lumeApiPull: vi.fn(),
lumeApiDelete: vi.fn(),
}));
// Mock pino logger
vi.mock("pino", () => ({
default: () => ({
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
}),
}));
describe("LumeComputer", () => {
const mockVMInfo: VMInfo = {
name: "test-vm",
status: "running",
diskSize: { allocated: 1024, total: 10240 },
memorySize: 2048,
os: "macos",
display: "1920x1080",
locationName: "local",
cpuCount: 4,
ipAddress: "192.168.1.100",
vncUrl: "vnc://localhost:5900",
sharedDirectories: [],
};
const defaultConfig: LumeComputerConfig = {
name: "test-vm",
osType: OSType.MACOS,
vmProvider: VMProviderType.LUME,
display: "1920x1080",
memory: "8GB",
cpu: 4,
image: "macos-sequoia-cua:latest",
port: 7777,
host: "localhost",
ephemeral: false,
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("constructor", () => {
it("should initialize with default config", () => {
const computer = new LumeComputer(defaultConfig);
expect(computer.getName()).toBe("test-vm");
expect(computer.getOSType()).toBe(OSType.MACOS);
expect(computer.getVMProviderType()).toBe(VMProviderType.LUME);
});
it("should accept display as string", () => {
const config = { ...defaultConfig, display: "1024x768" };
const computer = new LumeComputer(config);
expect(computer).toBeDefined();
});
it("should accept display as Display object", () => {
const display: Display = { width: 1920, height: 1080, scale_factor: 2 };
const config = { ...defaultConfig, display };
const computer = new LumeComputer(config);
expect(computer).toBeDefined();
});
});
describe("getVm", () => {
it("should get VM info successfully", async () => {
vi.mocked(lumeApi.lumeApiGet).mockResolvedValue([mockVMInfo]);
const computer = new LumeComputer(defaultConfig);
const result = await computer.getVm("test-vm");
expect(lumeApi.lumeApiGet).toHaveBeenCalledWith(
"test-vm",
"localhost",
7777,
undefined
);
expect(result).toEqual(mockVMInfo);
});
it("should handle VM not found error", async () => {
vi.mocked(lumeApi.lumeApiGet).mockResolvedValue([]);
const computer = new LumeComputer(defaultConfig);
await expect(computer.getVm("test-vm")).rejects.toThrow("VM Not Found.");
});
it("should handle stopped VM state", async () => {
const stoppedVM = {
...mockVMInfo,
status: "stopped",
ipAddress: undefined,
};
vi.mocked(lumeApi.lumeApiGet).mockResolvedValue([stoppedVM]);
const computer = new LumeComputer(defaultConfig);
const result = await computer.getVm("test-vm");
expect(result.status).toBe("stopped");
expect(result.name).toBe("test-vm");
});
it("should handle VM without IP address", async () => {
const noIpVM = { ...mockVMInfo, ipAddress: undefined };
vi.mocked(lumeApi.lumeApiGet).mockResolvedValue([noIpVM]);
const computer = new LumeComputer(defaultConfig);
const result = await computer.getVm("test-vm");
expect(result).toEqual(noIpVM);
});
it("should pass storage parameter", async () => {
vi.mocked(lumeApi.lumeApiGet).mockResolvedValue([mockVMInfo]);
const computer = new LumeComputer(defaultConfig);
await computer.getVm("test-vm", "/custom/storage");
expect(lumeApi.lumeApiGet).toHaveBeenCalledWith(
"test-vm",
"localhost",
7777,
"/custom/storage"
);
});
});
describe("listVm", () => {
it("should list all VMs", async () => {
const vmList = [mockVMInfo, { ...mockVMInfo, name: "another-vm" }];
vi.mocked(lumeApi.lumeApiGet).mockResolvedValue(vmList);
const computer = new LumeComputer(defaultConfig);
const result = await computer.listVm();
expect(lumeApi.lumeApiGet).toHaveBeenCalledWith("", "localhost", 7777);
expect(result).toEqual(vmList);
});
});
describe("runVm", () => {
it("should run VM when it already exists", async () => {
vi.mocked(lumeApi.lumeApiGet).mockResolvedValue([mockVMInfo]);
vi.mocked(lumeApi.lumeApiRun).mockResolvedValue(mockVMInfo);
const computer = new LumeComputer(defaultConfig);
const runOpts = { memory: "4GB" };
const result = await computer.runVm(
"macos-sequoia-cua:latest",
"test-vm",
runOpts
);
expect(lumeApi.lumeApiGet).toHaveBeenCalledWith(
"test-vm",
"localhost",
7777,
undefined
);
expect(lumeApi.lumeApiRun).toHaveBeenCalledWith(
"test-vm",
"localhost",
7777,
runOpts,
undefined
);
expect(result).toEqual(mockVMInfo);
});
it("should pull and run VM when it doesn't exist", async () => {
vi.mocked(lumeApi.lumeApiGet).mockRejectedValueOnce(
new Error("VM not found")
);
vi.mocked(lumeApi.lumeApiPull).mockResolvedValue(mockVMInfo);
vi.mocked(lumeApi.lumeApiRun).mockResolvedValue(mockVMInfo);
const computer = new LumeComputer(defaultConfig);
const result = await computer.runVm(
"macos-sequoia-cua:latest",
"test-vm"
);
expect(lumeApi.lumeApiGet).toHaveBeenCalled();
expect(lumeApi.lumeApiPull).toHaveBeenCalledWith(
"macos-sequoia-cua:latest",
"test-vm",
"localhost",
7777,
undefined,
"ghcr.io",
"trycua"
);
expect(lumeApi.lumeApiRun).toHaveBeenCalled();
expect(result).toEqual(mockVMInfo);
});
it("should handle pull failure", async () => {
vi.mocked(lumeApi.lumeApiGet).mockRejectedValueOnce(
new Error("VM not found")
);
vi.mocked(lumeApi.lumeApiPull).mockRejectedValue(
new Error("Pull failed")
);
const computer = new LumeComputer(defaultConfig);
await expect(
computer.runVm("macos-sequoia-cua:latest", "test-vm")
).rejects.toThrow("Pull failed");
});
it("should pass storage parameter", async () => {
vi.mocked(lumeApi.lumeApiGet).mockResolvedValue([mockVMInfo]);
vi.mocked(lumeApi.lumeApiRun).mockResolvedValue(mockVMInfo);
const computer = new LumeComputer(defaultConfig);
await computer.runVm(
"macos-sequoia-cua:latest",
"test-vm",
{},
"/storage"
);
expect(lumeApi.lumeApiGet).toHaveBeenCalledWith(
"test-vm",
"localhost",
7777,
"/storage"
);
expect(lumeApi.lumeApiRun).toHaveBeenCalledWith(
"test-vm",
"localhost",
7777,
{},
"/storage"
);
});
});
describe("stopVm", () => {
it("should stop VM normally", async () => {
vi.mocked(lumeApi.lumeApiStop).mockResolvedValue(mockVMInfo);
const computer = new LumeComputer(defaultConfig);
const result = await computer.stopVm("test-vm");
expect(lumeApi.lumeApiStop).toHaveBeenCalledWith(
"test-vm",
"localhost",
7777,
undefined
);
expect(result).toEqual(mockVMInfo);
});
it("should delete VM after stopping in ephemeral mode", async () => {
const stoppedVM = { ...mockVMInfo, status: "stopped" };
vi.mocked(lumeApi.lumeApiStop).mockResolvedValue(stoppedVM);
vi.mocked(lumeApi.lumeApiDelete).mockResolvedValue(null);
const ephemeralConfig = { ...defaultConfig, ephemeral: true };
const computer = new LumeComputer(ephemeralConfig);
const result = await computer.stopVm("test-vm");
expect(lumeApi.lumeApiStop).toHaveBeenCalled();
expect(lumeApi.lumeApiDelete).toHaveBeenCalledWith(
"test-vm",
"localhost",
7777,
undefined,
false,
false
);
expect(result).toMatchObject({
...stoppedVM,
deleted: true,
deleteResult: null,
});
});
it("should handle delete failure in ephemeral mode", async () => {
vi.mocked(lumeApi.lumeApiStop).mockResolvedValue(mockVMInfo);
vi.mocked(lumeApi.lumeApiDelete).mockRejectedValue(
new Error("Delete failed")
);
const ephemeralConfig = { ...defaultConfig, ephemeral: true };
const computer = new LumeComputer(ephemeralConfig);
await expect(computer.stopVm("test-vm")).rejects.toThrow(
"Failed to delete ephemeral VM test-vm: Error: Failed to delete VM: Error: Delete failed"
);
});
});
describe("pullVm", () => {
it("should pull VM image successfully", async () => {
vi.mocked(lumeApi.lumeApiPull).mockResolvedValue(mockVMInfo);
const computer = new LumeComputer(defaultConfig);
const result = await computer.pullVm("test-vm", "ubuntu:latest");
expect(lumeApi.lumeApiPull).toHaveBeenCalledWith(
"ubuntu:latest",
"test-vm",
"localhost",
7777,
undefined,
"ghcr.io",
"trycua"
);
expect(result).toEqual(mockVMInfo);
});
it("should throw error if image parameter is missing", async () => {
const computer = new LumeComputer(defaultConfig);
await expect(computer.pullVm("test-vm", "")).rejects.toThrow(
"Image parameter is required for pullVm"
);
});
it("should use custom registry and organization", async () => {
vi.mocked(lumeApi.lumeApiPull).mockResolvedValue(mockVMInfo);
const computer = new LumeComputer(defaultConfig);
await computer.pullVm(
"test-vm",
"custom:tag",
"/storage",
"docker.io",
"myorg"
);
expect(lumeApi.lumeApiPull).toHaveBeenCalledWith(
"custom:tag",
"test-vm",
"localhost",
7777,
"/storage",
"docker.io",
"myorg"
);
});
it("should handle pull failure", async () => {
vi.mocked(lumeApi.lumeApiPull).mockRejectedValue(
new Error("Network error")
);
const computer = new LumeComputer(defaultConfig);
await expect(computer.pullVm("test-vm", "ubuntu:latest")).rejects.toThrow(
"Failed to pull VM: Error: Network error"
);
});
});
describe("deleteVm", () => {
it("should delete VM successfully", async () => {
vi.mocked(lumeApi.lumeApiDelete).mockResolvedValue(null);
const computer = new LumeComputer(defaultConfig);
const result = await computer.deleteVm("test-vm");
expect(lumeApi.lumeApiDelete).toHaveBeenCalledWith(
"test-vm",
"localhost",
7777,
undefined,
false,
false
);
expect(result).toBeNull();
});
it("should handle delete failure", async () => {
vi.mocked(lumeApi.lumeApiDelete).mockRejectedValue(
new Error("Permission denied")
);
const computer = new LumeComputer(defaultConfig);
await expect(computer.deleteVm("test-vm")).rejects.toThrow(
"Failed to delete VM: Error: Permission denied"
);
});
it("should pass storage parameter", async () => {
vi.mocked(lumeApi.lumeApiDelete).mockResolvedValue(null);
const computer = new LumeComputer(defaultConfig);
await computer.deleteVm("test-vm", "/storage");
expect(lumeApi.lumeApiDelete).toHaveBeenCalledWith(
"test-vm",
"localhost",
7777,
"/storage",
false,
false
);
});
});
describe("updateVm", () => {
it("should update VM configuration", async () => {
const updatedVM = { ...mockVMInfo, memorySize: 4096 };
vi.mocked(lumeApi.lumeApiUpdate).mockResolvedValue(updatedVM);
const computer = new LumeComputer(defaultConfig);
const updateOpts = { memory: "4GB" };
const result = await computer.updateVm("test-vm", updateOpts);
expect(lumeApi.lumeApiUpdate).toHaveBeenCalledWith(
"test-vm",
"localhost",
7777,
updateOpts,
undefined,
false,
false
);
expect(result).toEqual(updatedVM);
});
it("should pass storage parameter", async () => {
vi.mocked(lumeApi.lumeApiUpdate).mockResolvedValue(mockVMInfo);
const computer = new LumeComputer(defaultConfig);
await computer.updateVm("test-vm", {}, "/storage");
expect(lumeApi.lumeApiUpdate).toHaveBeenCalledWith(
"test-vm",
"localhost",
7777,
{},
"/storage",
false,
false
);
});
});
describe("getIp", () => {
it("should return IP address immediately if available", async () => {
vi.mocked(lumeApi.lumeApiGet).mockResolvedValue([mockVMInfo]);
const computer = new LumeComputer(defaultConfig);
const ip = await computer.getIp("test-vm");
expect(ip).toBe("192.168.1.100");
expect(lumeApi.lumeApiGet).toHaveBeenCalledTimes(1);
});
it("should retry until IP address is available", async () => {
const noIpVM = { ...mockVMInfo, ipAddress: undefined };
vi.mocked(lumeApi.lumeApiGet)
.mockResolvedValueOnce([noIpVM])
.mockResolvedValueOnce([noIpVM])
.mockResolvedValueOnce([mockVMInfo]);
const computer = new LumeComputer(defaultConfig);
const ip = await computer.getIp("test-vm", undefined, 0.1); // Short retry delay for testing
expect(ip).toBe("192.168.1.100");
expect(lumeApi.lumeApiGet).toHaveBeenCalledTimes(3);
});
it("should throw error if VM is stopped", async () => {
const stoppedVM = {
...mockVMInfo,
status: "stopped",
ipAddress: undefined,
};
vi.mocked(lumeApi.lumeApiGet).mockResolvedValue([stoppedVM]);
const computer = new LumeComputer(defaultConfig);
await expect(computer.getIp("test-vm")).rejects.toThrow(
"VM test-vm is in 'stopped' state and will not get an IP address"
);
});
it("should throw error if VM is in error state", async () => {
const errorVM = { ...mockVMInfo, status: "error", ipAddress: undefined };
vi.mocked(lumeApi.lumeApiGet).mockResolvedValue([errorVM]);
const computer = new LumeComputer(defaultConfig);
await expect(computer.getIp("test-vm")).rejects.toThrow(
"VM test-vm is in 'error' state and will not get an IP address"
);
});
it("should handle getVm errors", async () => {
vi.mocked(lumeApi.lumeApiGet).mockRejectedValue(
new Error("Network error")
);
const computer = new LumeComputer(defaultConfig);
await expect(computer.getIp("test-vm")).rejects.toThrow("Network error");
});
it("should pass storage parameter", async () => {
vi.mocked(lumeApi.lumeApiGet).mockResolvedValue([mockVMInfo]);
const computer = new LumeComputer(defaultConfig);
await computer.getIp("test-vm", "/storage");
expect(lumeApi.lumeApiGet).toHaveBeenCalledWith(
"test-vm",
"localhost",
7777,
"/storage"
);
});
});
describe("integration scenarios", () => {
it("should handle full VM lifecycle", async () => {
// Simulate VM not existing initially
vi.mocked(lumeApi.lumeApiGet).mockRejectedValueOnce(
new Error("VM not found")
);
vi.mocked(lumeApi.lumeApiPull).mockResolvedValue(mockVMInfo);
// Simulate VM starting without IP, then getting IP
const startingVM = {
...mockVMInfo,
ipAddress: undefined,
status: "starting",
};
vi.mocked(lumeApi.lumeApiRun).mockResolvedValue(startingVM);
vi.mocked(lumeApi.lumeApiGet)
.mockResolvedValueOnce([startingVM])
.mockResolvedValueOnce([mockVMInfo]);
// Simulate stop
const stoppedVM = { ...mockVMInfo, status: "stopped" };
vi.mocked(lumeApi.lumeApiStop).mockResolvedValue(stoppedVM);
const computer = new LumeComputer(defaultConfig);
// Run VM (should pull first)
await computer.runVm("macos-sequoia-cua:latest", "test-vm");
// Get IP (should retry once)
const ip = await computer.getIp("test-vm", undefined, 0.1);
expect(ip).toBe("192.168.1.100");
// Stop VM
const stopResult = await computer.stopVm("test-vm");
expect(stopResult.status).toBe("stopped");
});
it("should handle ephemeral VM lifecycle", async () => {
const ephemeralConfig = { ...defaultConfig, ephemeral: true };
const computer = new LumeComputer(ephemeralConfig);
// Setup mocks
vi.mocked(lumeApi.lumeApiGet).mockResolvedValue([mockVMInfo]);
vi.mocked(lumeApi.lumeApiRun).mockResolvedValue(mockVMInfo);
vi.mocked(lumeApi.lumeApiStop).mockResolvedValue({
...mockVMInfo,
status: "stopped",
});
vi.mocked(lumeApi.lumeApiDelete).mockResolvedValue(null);
// Run and stop ephemeral VM
await computer.runVm("macos-sequoia-cua:latest", "test-vm");
const result = await computer.stopVm("test-vm");
// Verify VM was deleted
expect(lumeApi.lumeApiDelete).toHaveBeenCalled();
expect(result).toBe(null);
});
});
});
@@ -1,14 +0,0 @@
import { describe, it } from "vitest";
import { Computer, OSType, VMProviderType } from "../src/index";
describe("Cloud Interface", () => {
it("should create a cloud computer", () => {
const computer = Computer.create({
vmProvider: VMProviderType.CLOUD,
name: "computer-name",
size: "small",
osType: OSType.LINUX,
apiKey: "asdf",
});
});
});
@@ -1,597 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
lumeApiGet,
lumeApiRun,
lumeApiStop,
lumeApiUpdate,
lumeApiPull,
lumeApiDelete,
type VMInfo,
type RunOptions,
type UpdateOptions,
} from "../src/util/lume";
const PORT = 1213;
const HOST = "localhost";
describe("Lume API", () => {
describe("lumeApiGet", () => {
it("should fetch VM information successfully", async () => {
// Mock fetch for this test - API returns a single VMDetails object
const mockVMInfo: VMInfo = {
name: "test-vm",
status: "stopped",
diskSize: { allocated: 1024, total: 10240 },
memorySize: 2048,
os: "ubuntu",
display: "1920x1080",
locationName: "local",
cpuCount: 2,
sharedDirectories: [
{
hostPath: "/home/user/shared",
tag: "shared",
readOnly: false,
},
],
};
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockVMInfo,
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const result = await lumeApiGet("test-vm", HOST, PORT);
expect(result).toEqual([mockVMInfo]);
expect(fetch).toHaveBeenCalledWith(
`http://${HOST}:${PORT}/lume/vms/test-vm`,
expect.objectContaining({
method: "GET",
signal: expect.any(AbortSignal),
})
);
});
it("should list all VMs when name is empty", async () => {
// Mock fetch for list VMs - API returns an array
const mockVMList: VMInfo[] = [
{
name: "vm1",
status: "running",
diskSize: { allocated: 1024, total: 10240 },
memorySize: 2048,
os: "ubuntu",
display: "1920x1080",
locationName: "local",
cpuCount: 2,
},
{
name: "vm2",
status: "stopped",
diskSize: { allocated: 2048, total: 10240 },
memorySize: 4096,
os: "debian",
display: "1920x1080",
locationName: "local",
cpuCount: 4,
},
];
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockVMList,
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const result = await lumeApiGet("", HOST, PORT);
expect(result).toEqual(mockVMList);
expect(fetch).toHaveBeenCalledWith(
`http://${HOST}:${PORT}/lume/vms`,
expect.objectContaining({
method: "GET",
signal: expect.any(AbortSignal),
})
);
});
it("should handle storage parameter encoding correctly", async () => {
const mockVMInfo: VMInfo = {
name: "test-vm",
status: "stopped",
diskSize: { allocated: 1024, total: 10240 },
memorySize: 2048,
os: "ubuntu",
display: "1920x1080",
locationName: "local",
cpuCount: 2,
};
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockVMInfo,
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const storage = "/path/with spaces/and#special?chars";
await lumeApiGet("test-vm", HOST, PORT, storage);
const expectedUrl = `http://${HOST}:${PORT}/lume/vms/test-vm?storage=${encodeURIComponent(
storage
)}`;
expect(fetch).toHaveBeenCalledWith(
expectedUrl,
expect.objectContaining({
method: "GET",
})
);
});
it("should handle HTTP errors", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 404,
headers: new Headers(),
} as Response);
await expect(lumeApiGet("test-vm", HOST, PORT)).rejects.toThrow(
"API request failed: HTTP error returned from API server (status: 404)"
);
});
it("should handle connection refused errors", async () => {
const error = new Error("Connection refused");
(error as Error).message = "ECONNREFUSED";
global.fetch = vi.fn().mockRejectedValueOnce(error);
await expect(lumeApiGet("test-vm", HOST, PORT)).rejects.toThrow(
"API request failed: Failed to connect to the API server - it might still be starting up"
);
});
it("should handle timeout errors", async () => {
const error = new Error("Request aborted");
error.name = "AbortError";
global.fetch = vi.fn().mockRejectedValueOnce(error);
await expect(lumeApiGet("test-vm", HOST, PORT)).rejects.toThrow(
"API request failed: Operation timeout - the API server is taking too long to respond"
);
});
it("should handle host not found errors", async () => {
const error = new Error("Host not found");
(error as Error).message = "ENOTFOUND";
global.fetch = vi.fn().mockRejectedValueOnce(error);
await expect(lumeApiGet("test-vm", HOST, PORT)).rejects.toThrow(
"API request failed: Failed to resolve host - check the API server address"
);
});
});
describe("lumeApiRun", () => {
it("should run a VM successfully", async () => {
const mockVMInfo: VMInfo = {
name: "test-vm",
status: "running",
diskSize: { allocated: 1024, total: 10240 },
memorySize: 2048,
os: "ubuntu",
display: "1920x1080",
locationName: "local",
cpuCount: 2,
vncUrl: "vnc://localhost:5900",
ipAddress: "192.168.1.100",
};
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockVMInfo,
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const runOpts: RunOptions = {
memory: "2G",
cpus: 2,
display: "1920x1080",
};
const result = await lumeApiRun("test-vm", HOST, PORT, runOpts);
expect(result).toEqual(mockVMInfo);
expect(fetch).toHaveBeenCalledWith(
`http://${HOST}:${PORT}/lume/vms/test-vm/run`,
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(runOpts),
signal: expect.any(AbortSignal),
})
);
});
it("should handle storage parameter in run request", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ name: "test-vm" }),
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const storage = "/custom/storage/path";
const runOpts: RunOptions = { memory: "1G" };
await lumeApiRun("test-vm", HOST, PORT, runOpts, storage);
const expectedUrl = `http://${HOST}:${PORT}/lume/vms/test-vm/run?storage=${encodeURIComponent(
storage
)}`;
expect(fetch).toHaveBeenCalledWith(
expectedUrl,
expect.objectContaining({
method: "POST",
})
);
});
it("should handle run errors", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 500,
headers: new Headers(),
} as Response);
await expect(lumeApiRun("test-vm", HOST, PORT, {})).rejects.toThrow(
"API request failed: HTTP error returned from API server (status: 500)"
);
});
});
describe("lumeApiStop", () => {
it("should stop a VM successfully", async () => {
const mockVMInfo: VMInfo = {
name: "test-vm",
status: "stopped",
diskSize: { allocated: 1024, total: 10240 },
memorySize: 2048,
os: "ubuntu",
display: "1920x1080",
locationName: "local",
cpuCount: 2,
};
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockVMInfo,
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const result = await lumeApiStop("test-vm", HOST, PORT);
expect(result).toEqual(mockVMInfo);
expect(fetch).toHaveBeenCalledWith(
`http://${HOST}:${PORT}/lume/vms/test-vm/stop`,
expect.objectContaining({
method: "POST",
signal: expect.any(AbortSignal),
headers: {
"Content-Type": "application/json",
},
body: "{}",
})
);
});
it("should handle storage parameter in stop request", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ name: "test-vm" }),
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const storage = "/storage/path";
await lumeApiStop("test-vm", HOST, PORT, storage);
const expectedUrl = `http://${HOST}:${PORT}/lume/vms/test-vm/stop?storage=${encodeURIComponent(
storage
)}`;
expect(fetch).toHaveBeenCalledWith(
expectedUrl,
expect.objectContaining({
method: "POST",
})
);
});
});
describe("lumeApiUpdate", () => {
it("should update VM settings successfully", async () => {
const mockVMInfo: VMInfo = {
name: "test-vm",
status: "stopped",
diskSize: { allocated: 1024, total: 10240 },
memorySize: 4096,
os: "ubuntu",
display: "2560x1440",
locationName: "local",
cpuCount: 2,
};
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockVMInfo,
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const updateOpts: UpdateOptions = {
memory: "4G",
display: "2560x1440",
};
const result = await lumeApiUpdate("test-vm", HOST, PORT, updateOpts);
expect(result).toEqual(mockVMInfo);
expect(fetch).toHaveBeenCalledWith(
`http://${HOST}:${PORT}/lume/vms/test-vm/update`,
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(updateOpts),
signal: expect.any(AbortSignal),
})
);
});
it("should handle empty update options", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ name: "test-vm" }),
headers: new Headers({ "content-type": "application/json" }),
} as Response);
await lumeApiUpdate("test-vm", HOST, PORT, {});
expect(fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: "{}",
})
);
});
});
describe("lumeApiPull", () => {
it("should pull a VM image successfully", async () => {
const mockVMInfo: VMInfo = {
name: "pulled-vm",
status: "stopped",
diskSize: { allocated: 2048, total: 10240 },
memorySize: 2048,
os: "ubuntu",
display: "1920x1080",
locationName: "local",
cpuCount: 2,
};
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockVMInfo,
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const result = await lumeApiPull(
"ubuntu:latest",
"pulled-vm",
HOST,
PORT
);
expect(result).toEqual(mockVMInfo);
expect(fetch).toHaveBeenCalledWith(
`http://${HOST}:${PORT}/lume/pull`,
expect.objectContaining({
method: "POST",
signal: expect.any(AbortSignal),
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image: "ubuntu:latest",
name: "pulled-vm",
registry: "ghcr.io",
organization: "trycua",
}),
})
);
});
it("should use custom registry and organization", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ name: "custom-vm" }),
headers: new Headers({ "content-type": "application/json" }),
} as Response);
await lumeApiPull(
"custom:tag",
"custom-vm",
HOST,
PORT,
undefined,
"docker.io",
"myorg"
);
expect(fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: JSON.stringify({
image: "custom:tag",
name: "custom-vm",
registry: "docker.io",
organization: "myorg",
}),
})
);
});
it("should handle storage parameter in pull request", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ name: "test-vm" }),
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const storage = "/custom/storage";
await lumeApiPull("image:tag", "test-vm", HOST, PORT, storage);
const expectedUrl = `http://${HOST}:${PORT}/lume/pull?storage=${encodeURIComponent(
storage
)}`;
expect(fetch).toHaveBeenCalledWith(
expectedUrl,
expect.objectContaining({
method: "POST",
})
);
});
});
describe("lumeApiDelete", () => {
it("should delete a VM successfully", async () => {
const mockVMInfo: VMInfo = {
name: "test-vm",
status: "deleted",
diskSize: { allocated: 0, total: 0 },
memorySize: 0,
os: "ubuntu",
display: "",
locationName: "local",
cpuCount: 0,
};
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockVMInfo,
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const result = await lumeApiDelete("test-vm", HOST, PORT);
expect(result).toEqual(mockVMInfo);
expect(fetch).toHaveBeenCalledWith(
`http://${HOST}:${PORT}/lume/vms/test-vm`,
expect.objectContaining({
method: "DELETE",
signal: expect.any(AbortSignal),
})
);
});
it("should handle storage parameter in delete request", async () => {
const storage = "/custom/storage";
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => null,
headers: new Headers(),
} as Response);
await lumeApiDelete("test-vm", HOST, PORT, storage);
const expectedUrl = `http://${HOST}:${PORT}/lume/vms/test-vm?storage=${encodeURIComponent(
storage
)}`;
expect(fetch).toHaveBeenCalledWith(
expectedUrl,
expect.objectContaining({
method: "DELETE",
})
);
});
it("should handle 404 as successful deletion", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 404,
headers: new Headers(),
} as Response);
const result = await lumeApiDelete("non-existent-vm", HOST, PORT);
expect(result).toBeNull();
});
it("should throw error for non-404 HTTP errors", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 500,
headers: new Headers(),
} as Response);
await expect(lumeApiDelete("test-vm", HOST, PORT)).rejects.toThrow(
"API request failed: HTTP error returned from API server (status: 500)"
);
});
});
describe("Debug and Verbose Logging", () => {
it("should log debug information when debug is true", async () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => [{ name: "test-vm", cpuCount: 2 }],
headers: new Headers({ "content-type": "application/json" }),
} as Response);
await lumeApiGet("", HOST, PORT, undefined, true); // Empty name for list
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("DEBUG: API response:")
);
consoleSpy.mockRestore();
});
it("should log verbose information when verbose is true", async () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ name: "test-vm", cpuCount: 2 }),
headers: new Headers({ "content-type": "application/json" }),
} as Response);
await lumeApiRun(
"test-vm",
HOST,
PORT,
{ memory: "1G" },
undefined,
false,
true
);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe("Error Message Handling", () => {
it("should handle generic errors with message", async () => {
const error = new Error("Custom error message");
global.fetch = vi.fn().mockRejectedValueOnce(error);
await expect(lumeApiGet("test-vm", HOST, PORT)).rejects.toThrow(
"API request failed: Custom error message"
);
});
});
});