Finish writing tests for interfaces

This commit is contained in:
Morgan Dean
2025-06-19 14:48:38 -07:00
parent 593afe220f
commit 613745da7f
7 changed files with 707 additions and 728 deletions
-1
View File
@@ -48,7 +48,6 @@
"@types/ws": "^8.18.1",
"bumpp": "^10.1.0",
"happy-dom": "^17.4.7",
"msw": "^2.10.2",
"tsdown": "^0.11.9",
"tsx": "^4.19.4",
"typescript": "^5.8.3",
+82 -34
View File
@@ -33,9 +33,6 @@ importers:
happy-dom:
specifier: ^17.4.7
version: 17.6.3
msw:
specifier: ^2.10.2
version: 2.10.2(@types/node@22.15.31)(typescript@5.8.3)
tsdown:
specifier: ^0.11.9
version: 0.11.13(typescript@5.8.3)
@@ -1420,15 +1417,18 @@ snapshots:
'@bundled-es-modules/cookie@2.0.1':
dependencies:
cookie: 0.7.2
optional: true
'@bundled-es-modules/statuses@1.0.1':
dependencies:
statuses: 2.0.2
optional: true
'@bundled-es-modules/tough-cookie@0.1.6':
dependencies:
'@types/tough-cookie': 4.0.5
tough-cookie: 4.1.4
optional: true
'@emnapi/core@1.4.3':
dependencies:
@@ -1602,6 +1602,7 @@ snapshots:
'@inquirer/type': 3.0.7(@types/node@22.15.31)
optionalDependencies:
'@types/node': 22.15.31
optional: true
'@inquirer/core@10.1.13(@types/node@22.15.31)':
dependencies:
@@ -1615,12 +1616,15 @@ snapshots:
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.15.31
optional: true
'@inquirer/figures@1.0.12': {}
'@inquirer/figures@1.0.12':
optional: true
'@inquirer/type@3.0.7(@types/node@22.15.31)':
optionalDependencies:
'@types/node': 22.15.31
optional: true
'@jridgewell/gen-mapping@0.3.8':
dependencies:
@@ -1647,6 +1651,7 @@ snapshots:
is-node-process: 1.2.0
outvariant: 1.4.3
strict-event-emitter: 0.5.1
optional: true
'@napi-rs/wasm-runtime@0.2.11':
dependencies:
@@ -1655,14 +1660,17 @@ snapshots:
'@tybys/wasm-util': 0.9.0
optional: true
'@open-draft/deferred-promise@2.2.0': {}
'@open-draft/deferred-promise@2.2.0':
optional: true
'@open-draft/logger@0.3.0':
dependencies:
is-node-process: 1.2.0
outvariant: 1.4.3
optional: true
'@open-draft/until@2.1.0': {}
'@open-draft/until@2.1.0':
optional: true
'@oxc-project/types@0.70.0': {}
@@ -1779,7 +1787,8 @@ snapshots:
dependencies:
'@types/deep-eql': 4.0.2
'@types/cookie@0.6.0': {}
'@types/cookie@0.6.0':
optional: true
'@types/debug@4.1.12':
dependencies:
@@ -1799,9 +1808,11 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/statuses@2.0.6': {}
'@types/statuses@2.0.6':
optional: true
'@types/tough-cookie@4.0.5': {}
'@types/tough-cookie@4.0.5':
optional: true
'@types/ws@8.18.1':
dependencies:
@@ -1853,12 +1864,15 @@ snapshots:
ansi-escapes@4.3.2:
dependencies:
type-fest: 0.21.3
optional: true
ansi-regex@5.0.1: {}
ansi-regex@5.0.1:
optional: true
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
optional: true
ansis@4.1.0: {}
@@ -1926,13 +1940,15 @@ snapshots:
dependencies:
consola: 3.4.2
cli-width@4.1.0: {}
cli-width@4.1.0:
optional: true
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
optional: true
color-convert@2.0.1:
dependencies:
@@ -1954,7 +1970,8 @@ snapshots:
consola@3.4.2: {}
cookie@0.7.2: {}
cookie@0.7.2:
optional: true
debug@4.4.1:
dependencies:
@@ -1974,7 +1991,8 @@ snapshots:
dts-resolver@2.1.1: {}
emoji-regex@8.0.0: {}
emoji-regex@8.0.0:
optional: true
empathic@1.1.0: {}
@@ -2027,7 +2045,8 @@ snapshots:
fsevents@2.3.3:
optional: true
get-caller-file@2.0.5: {}
get-caller-file@2.0.5:
optional: true
get-tsconfig@4.10.1:
dependencies:
@@ -2042,22 +2061,26 @@ snapshots:
nypm: 0.6.0
pathe: 2.0.3
graphql@16.11.0: {}
graphql@16.11.0:
optional: true
happy-dom@17.6.3:
dependencies:
webidl-conversions: 7.0.0
whatwg-mimetype: 3.0.0
headers-polyfill@4.0.3: {}
headers-polyfill@4.0.3:
optional: true
hookable@5.5.3: {}
is-arrayish@0.3.2: {}
is-fullwidth-code-point@3.0.0: {}
is-fullwidth-code-point@3.0.0:
optional: true
is-node-process@1.2.0: {}
is-node-process@1.2.0:
optional: true
jiti@2.4.2: {}
@@ -2099,8 +2122,10 @@ snapshots:
typescript: 5.8.3
transitivePeerDependencies:
- '@types/node'
optional: true
mute-stream@2.0.0: {}
mute-stream@2.0.0:
optional: true
nanoid@3.3.11: {}
@@ -2118,11 +2143,13 @@ snapshots:
on-exit-leak-free@2.1.2: {}
outvariant@1.4.3: {}
outvariant@1.4.3:
optional: true
package-manager-detector@1.3.0: {}
path-to-regexp@6.3.0: {}
path-to-regexp@6.3.0:
optional: true
pathe@2.0.3: {}
@@ -2171,12 +2198,15 @@ snapshots:
psl@1.15.0:
dependencies:
punycode: 2.3.1
optional: true
punycode@2.3.1: {}
punycode@2.3.1:
optional: true
quansync@0.2.10: {}
querystringify@2.2.0: {}
querystringify@2.2.0:
optional: true
quick-format-unescaped@4.0.4: {}
@@ -2189,9 +2219,11 @@ snapshots:
real-require@0.2.0: {}
require-directory@2.1.1: {}
require-directory@2.1.1:
optional: true
requires-port@1.0.0: {}
requires-port@1.0.0:
optional: true
resolve-pkg-maps@1.0.0: {}
@@ -2289,7 +2321,8 @@ snapshots:
siginfo@2.0.0: {}
signal-exit@4.1.0: {}
signal-exit@4.1.0:
optional: true
simple-swizzle@0.2.2:
dependencies:
@@ -2305,21 +2338,25 @@ snapshots:
stackback@0.0.2: {}
statuses@2.0.2: {}
statuses@2.0.2:
optional: true
std-env@3.9.0: {}
strict-event-emitter@0.5.1: {}
strict-event-emitter@0.5.1:
optional: true
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
optional: true
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
optional: true
strip-literal@3.0.0:
dependencies:
@@ -2352,6 +2389,7 @@ snapshots:
punycode: 2.3.1
universalify: 0.2.0
url-parse: 1.5.10
optional: true
tsdown@0.11.13(typescript@5.8.3):
dependencies:
@@ -2387,9 +2425,11 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
type-fest@0.21.3: {}
type-fest@0.21.3:
optional: true
type-fest@4.41.0: {}
type-fest@4.41.0:
optional: true
typescript@5.8.3: {}
@@ -2402,12 +2442,14 @@ snapshots:
undici-types@6.21.0: {}
universalify@0.2.0: {}
universalify@0.2.0:
optional: true
url-parse@1.5.10:
dependencies:
querystringify: 2.2.0
requires-port: 1.0.0
optional: true
vite-node@3.2.3(@types/node@22.15.31)(jiti@2.4.2)(tsx@4.20.2)(yaml@2.8.0):
dependencies:
@@ -2502,20 +2544,24 @@ snapshots:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
optional: true
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
optional: true
ws@8.18.2: {}
y18n@5.0.8: {}
y18n@5.0.8:
optional: true
yaml@2.8.0: {}
yargs-parser@21.1.1: {}
yargs-parser@21.1.1:
optional: true
yargs@17.7.2:
dependencies:
@@ -2526,5 +2572,7 @@ snapshots:
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
optional: true
yoctocolors-cjs@2.1.2: {}
yoctocolors-cjs@2.1.2:
optional: true
+13 -4
View File
@@ -39,6 +39,7 @@ export abstract class BaseComputerInterface {
protected ws: WebSocket;
protected apiKey?: string;
protected vmName?: string;
protected secure?: boolean;
protected logger = pino({ name: "interface-base" });
@@ -47,13 +48,15 @@ export abstract class BaseComputerInterface {
username = "lume",
password = "lume",
apiKey?: string,
vmName?: string
vmName?: string,
secure?: boolean
) {
this.ipAddress = ipAddress;
this.username = username;
this.password = password;
this.apiKey = apiKey;
this.vmName = vmName;
this.secure = secure;
// Initialize WebSocket with headers if needed
const headers: { [key: string]: string } = {};
@@ -71,9 +74,15 @@ export abstract class BaseComputerInterface {
* 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";
const protocol = this.secure ? "wss" : "ws";
// Check if ipAddress already includes a port
if (this.ipAddress.includes(":")) {
return `${protocol}://${this.ipAddress}/ws`;
}
// Otherwise, append the default port
const port = this.secure ? "8443" : "8000";
return `${protocol}://${this.ipAddress}:${port}/ws`;
}
@@ -1,447 +0,0 @@
import {
describe,
expect,
it,
beforeEach,
afterEach,
vi,
beforeAll,
afterAll,
} from "vitest";
import { InterfaceFactory } from "../../src/interface/factory.ts";
import { OSType } from "../../src/types.ts";
import { ws } from "msw";
import { setupServer } from "msw/node";
describe("Interface Integration Tests", () => {
const testIp = "192.168.1.100";
const testPort = 8000;
// Create WebSocket server
const server = setupServer();
beforeAll(() => {
server.listen({ onUnhandledRequest: "error" });
});
afterAll(() => {
server.close();
});
beforeEach(() => {
// Reset handlers for each test
server.resetHandlers();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Cross-platform interface creation", () => {
it("should create correct interface for each OS type", async () => {
const osTypes = [OSType.MACOS, OSType.LINUX, OSType.WINDOWS];
const interfaces: Array<{
os: OSType;
interface: ReturnType<typeof InterfaceFactory.createInterfaceForOS>;
}> = [];
// Create interfaces for each OS
for (const os of osTypes) {
const interface_ = InterfaceFactory.createInterfaceForOS(os, testIp);
interfaces.push({ os, interface: interface_ });
}
// Verify each interface is created correctly
expect(interfaces).toHaveLength(3);
for (const { os, interface: iface } of interfaces) {
expect(iface).toBeDefined();
// Check that the interface name contains the OS type in some form
const osName = os.toLowerCase();
expect(iface.constructor.name.toLowerCase()).toContain(osName);
}
});
it("should handle multiple interfaces with different IPs", async () => {
const ips = ["192.168.1.100", "192.168.1.101", "192.168.1.102"];
const interfaces = ips.map((ip) =>
InterfaceFactory.createInterfaceForOS(OSType.MACOS, ip)
);
// Set up WebSocket handlers for each IP
for (const ip of ips) {
const wsLink = ws.link(`ws://${ip}:${testPort}/ws`);
server.use(
wsLink.addEventListener("connection", ({ client }) => {
client.addEventListener("message", () => {
// Echo back success response
client.send(JSON.stringify({ success: true }));
});
})
);
}
// Connect all interfaces
await Promise.all(interfaces.map((iface) => iface.connect()));
// Verify all are connected
for (const iface of interfaces) {
expect(iface.isConnected()).toBe(true);
}
// Clean up
for (const iface of interfaces) {
iface.disconnect();
}
});
});
describe("Connection management", () => {
it("should handle connection lifecycle", async () => {
// Set up WebSocket handler
const wsLink = ws.link(`ws://${testIp}:${testPort}/ws`);
server.use(
wsLink.addEventListener("connection", ({ client }) => {
client.addEventListener("message", () => {
// Echo back success response
client.send(JSON.stringify({ success: true }));
});
})
);
const interface_ = InterfaceFactory.createInterfaceForOS(
OSType.MACOS,
testIp
);
// Initially not connected
expect(interface_.isConnected()).toBe(false);
// Connect
await interface_.connect();
expect(interface_.isConnected()).toBe(true);
// Disconnect
interface_.disconnect();
// Wait a tick for the close to process
await new Promise((resolve) => process.nextTick(resolve));
expect(interface_.isConnected()).toBe(false);
});
it("should handle connection errors gracefully", async () => {
// Don't register a handler - connection will succeed but no responses
const interface_ = InterfaceFactory.createInterfaceForOS(
OSType.MACOS,
"192.0.2.1" // TEST-NET-1 address
);
// Should connect (WebSocket mock always connects)
await interface_.connect();
expect(interface_.isConnected()).toBe(true);
interface_.disconnect();
});
it("should handle secure connections", async () => {
const secureIp = "192.0.2.1";
const securePort = 8443;
// Register handler for secure connection
const wsLink = ws.link(`wss://${secureIp}:${securePort}/ws`);
server.use(
wsLink.addEventListener("connection", ({ client }) => {
client.addEventListener("message", () => {
// Echo back success response
client.send(JSON.stringify({ success: true }));
});
})
);
const interface_ = InterfaceFactory.createInterfaceForOS(
OSType.MACOS,
secureIp,
"testuser",
"testpass"
);
await interface_.connect();
expect(interface_.isConnected()).toBe(true);
interface_.disconnect();
});
});
describe("Performance and concurrency", () => {
it("should handle rapid command sequences", async () => {
const receivedCommands: string[] = [];
// Set up WebSocket handler that tracks commands
const wsLink = ws.link(`ws://${testIp}:${testPort}/ws`);
server.use(
wsLink.addEventListener("connection", ({ client }) => {
client.addEventListener("message", (event) => {
const data = JSON.parse(event.data as string);
receivedCommands.push(data.action);
// Send response with command index
client.send(
JSON.stringify({
success: true,
data: `Response for ${data.action}`,
})
);
});
})
);
const interface_ = InterfaceFactory.createInterfaceForOS(
OSType.MACOS,
testIp
);
await interface_.connect();
// Send multiple commands rapidly
const commands = ["left_click", "right_click", "double_click"];
const promises = commands.map((cmd) => {
switch (cmd) {
case "left_click":
return interface_.leftClick(100, 200);
case "right_click":
return interface_.rightClick(150, 250);
case "double_click":
return interface_.doubleClick(200, 300);
}
});
await Promise.all(promises);
// Verify all commands were received
expect(receivedCommands).toHaveLength(3);
expect(receivedCommands).toContain("left_click");
expect(receivedCommands).toContain("right_click");
expect(receivedCommands).toContain("double_click");
interface_.disconnect();
});
it("should maintain command order with locking", async () => {
const receivedCommands: Array<{ action: string; index: number }> = [];
// Set up WebSocket handler that tracks commands with delay
const wsLink = ws.link(`ws://${testIp}:${testPort}/ws`);
server.use(
wsLink.addEventListener("connection", ({ client }) => {
client.addEventListener("message", async (event) => {
// Add delay to simulate processing
await new Promise((resolve) => setTimeout(resolve, 10));
const data = JSON.parse(event.data as string);
receivedCommands.push({
action: data.action,
index: data.index,
});
client.send(JSON.stringify({ success: true }));
});
})
);
const interface_ = InterfaceFactory.createInterfaceForOS(
OSType.MACOS,
testIp
);
await interface_.connect();
// Helper to send command with index
async function sendCommandWithIndex(action: string, index: number) {
await interface_.sendCommand({ action, index });
}
// Send commands in sequence
await sendCommandWithIndex("command1", 0);
await sendCommandWithIndex("command2", 1);
await sendCommandWithIndex("command3", 2);
// Wait for all commands to be processed
await new Promise((resolve) => setTimeout(resolve, 50));
// Verify commands were received in order
expect(receivedCommands).toHaveLength(3);
expect(receivedCommands[0]).toEqual({ action: "command1", index: 0 });
expect(receivedCommands[1]).toEqual({ action: "command2", index: 1 });
expect(receivedCommands[2]).toEqual({ action: "command3", index: 2 });
interface_.disconnect();
});
});
describe("Error handling", () => {
it("should handle command failures", async () => {
// Set up WebSocket handler that returns errors
const wsLink = ws.link(`ws://${testIp}:${testPort}/ws`);
server.use(
wsLink.addEventListener("connection", ({ client }) => {
client.addEventListener("message", (event) => {
const data = JSON.parse(event.data as string);
if (data.action === "fail_command") {
client.send(
JSON.stringify({
success: false,
error: "Command failed",
})
);
} else {
client.send(JSON.stringify({ success: true }));
}
});
})
);
const interface_ = InterfaceFactory.createInterfaceForOS(
OSType.MACOS,
testIp
);
await interface_.connect();
// Send a failing command
await expect(
interface_.sendCommand({ action: "fail_command" })
).rejects.toThrow("Command failed");
// Verify interface is still connected
expect(interface_.isConnected()).toBe(true);
// Send a successful command
const result = await interface_.sendCommand({
action: "success_command",
});
expect(result.success).toBe(true);
interface_.disconnect();
});
it("should handle disconnection during command", async () => {
// Set up WebSocket handler that captures WebSocket instance
const wsLink = ws.link(`ws://${testIp}:${testPort}/ws`);
server.use(
wsLink.addEventListener("connection", ({ client }) => {
client.addEventListener("message", async () => {
// Simulate disconnection during command processing
await new Promise((resolve) => setTimeout(resolve, 10));
client.close();
});
})
);
const interface_ = InterfaceFactory.createInterfaceForOS(
OSType.MACOS,
testIp
);
await interface_.connect();
// Send command that will trigger disconnection
await expect(
interface_.sendCommand({ action: "disconnect_me" })
).rejects.toThrow();
// Wait for close to process
await new Promise((resolve) => setTimeout(resolve, 20));
// Verify interface is disconnected
expect(interface_.isConnected()).toBe(false);
});
});
describe("Feature-specific tests", () => {
it("should handle screenshot commands", async () => {
// Set up WebSocket handler
const wsLink = ws.link(`ws://${testIp}:${testPort}/ws`);
server.use(
wsLink.addEventListener("connection", ({ client }) => {
client.addEventListener("message", () => {
// Echo back success response with screenshot data
client.send(JSON.stringify({
success: true,
data: "base64encodedimage"
}));
});
})
);
const interface_ = InterfaceFactory.createInterfaceForOS(
OSType.MACOS,
testIp
);
await interface_.connect();
const screenshot = await interface_.screenshot();
expect(screenshot).toBeInstanceOf(Buffer);
expect(screenshot.toString("base64")).toBe("base64encodedimage");
interface_.disconnect();
});
it("should handle screen size queries", async () => {
// Set up WebSocket handler
const wsLink = ws.link(`ws://${testIp}:${testPort}/ws`);
server.use(
wsLink.addEventListener("connection", ({ client }) => {
client.addEventListener("message", () => {
// Echo back success response with screen size
client.send(JSON.stringify({
success: true,
data: { width: 1920, height: 1080 }
}));
});
})
);
const interface_ = InterfaceFactory.createInterfaceForOS(
OSType.LINUX,
testIp
);
await interface_.connect();
const screenSize = await interface_.getScreenSize();
expect(screenSize).toEqual({ width: 1920, height: 1080 });
interface_.disconnect();
});
it("should handle file operations", async () => {
// Set up WebSocket handler
const wsLink = ws.link(`ws://${testIp}:${testPort}/ws`);
server.use(
wsLink.addEventListener("connection", ({ client }) => {
client.addEventListener("message", () => {
// Echo back success response with file data
client.send(JSON.stringify({
success: true,
data: "file content"
}));
});
})
);
const interface_ = InterfaceFactory.createInterfaceForOS(
OSType.WINDOWS,
testIp
);
await interface_.connect();
// Test file exists
const exists = await interface_.fileExists("/test/file.txt");
expect(exists).toBe(true);
// Test read text
const content = await interface_.readText("/test/file.txt");
expect(content).toBe("file content");
// Test list directory
const files = await interface_.listDir("/test");
expect(files).toEqual(["file1.txt", "file2.txt", "dir1"]);
interface_.disconnect();
});
});
});
@@ -4,7 +4,7 @@ import { MacOSComputerInterface } from "../../src/interface/macos.ts";
describe("LinuxComputerInterface", () => {
const testParams = {
ipAddress: "192.0.2.1", // TEST-NET-1 address (RFC 5737) - guaranteed not to be routable
ipAddress: "test.cua.com", // TEST-NET-1 address (RFC 5737) - guaranteed not to be routable
username: "testuser",
password: "testpass",
apiKey: "test-api-key",
File diff suppressed because it is too large Load Diff
+3 -22
View File
@@ -1,26 +1,7 @@
import { afterAll, afterEach, beforeAll } from "vitest";
import { setupServer } from "msw/node";
import { ws } from "msw";
const chat = ws.link("wss://chat.example.com");
beforeAll(() => {});
const wsHandlers = [
chat.addEventListener("connection", ({ client }) => {
client.addEventListener("message", (event) => {
console.log("Received message from client:", event.data);
// Echo the received message back to the client
client.send(`Server received: ${event.data}`);
});
}),
];
afterAll(() => {});
const server = setupServer(...wsHandlers);
// Start server before all tests
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
// Close server after all tests
afterAll(() => server.close());
// Reset handlers after each test for test isolation
afterEach(() => server.resetHandlers());
afterEach(() => {});