mirror of
https://github.com/trycua/computer.git
synced 2026-04-28 11:10:17 -05:00
Implement interfaces, reorganize imports
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+27
@@ -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 +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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user