Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 58 additions & 36 deletions ui/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ export async function sendKeypress(page: Page, keyCode: number, press: boolean):
);
}

export async function sendText(page: Page, text: string): Promise<void> {
await page.evaluate(t => window.__kvmTestHooks?.sendText(t), text);
}

export async function tapKey(page: Page, keyCode: number, holdMs = 20): Promise<void> {
await sendKeypress(page, keyCode, true);
await page.waitForTimeout(holdMs);
Expand Down Expand Up @@ -129,7 +133,9 @@ export async function waitForVideoDimensions(
.poll(
async () => {
dims = await getVideoStreamDimensions(page);
return dims !== null && dims.width > MIN_VIDEO_DIMENSION && dims.height > MIN_VIDEO_DIMENSION;
return (
dims !== null && dims.width > MIN_VIDEO_DIMENSION && dims.height > MIN_VIDEO_DIMENSION
);
},
{
message: "Waiting for video dimensions to be available",
Expand Down Expand Up @@ -501,9 +507,7 @@ export async function loginLocal(
page
.waitForURL(url => !url.toString().includes("/login"), { timeout: 5000 })
.then(() => "navigated" as const),
errorLocator
.waitFor({ state: "visible", timeout: 5000 })
.then(() => "error" as const),
errorLocator.waitFor({ state: "visible", timeout: 5000 }).then(() => "error" as const),
]).catch(() => "timeout" as const);

if (outcome === "navigated") {
Expand Down Expand Up @@ -699,9 +703,10 @@ export async function ensureLocalAuthMode(page: Page, desired: LocalAuthModeConf

if (currentUrl.includes("/login")) {
// Device has password protection - try to login with known passwords
const passwordsToTry = desired.mode === "password"
? [desired.password, ...KNOWN_TEST_PASSWORDS.filter(p => p !== desired.password)]
: [...KNOWN_TEST_PASSWORDS];
const passwordsToTry =
desired.mode === "password"
? [desired.password, ...KNOWN_TEST_PASSWORDS.filter(p => p !== desired.password)]
: [...KNOWN_TEST_PASSWORDS];

let loggedIn = false;
let usedPassword: string | null = null;
Expand Down Expand Up @@ -881,10 +886,17 @@ export async function callJsonRpc(
return new Promise((resolve, reject) => {
const hooks = window.__kvmTestHooks;
if (!hooks) return reject(new Error("Test hooks not available"));
hooks.sendJsonRpc(method, params, (resp: { error?: { message: string; data?: string }; result?: unknown }) => {
if (resp.error) reject(new Error(`${resp.error.message}${resp.error.data ? `: ${resp.error.data}` : ""}`));
else resolve(resp.result);
});
hooks.sendJsonRpc(
method,
params,
(resp: { error?: { message: string; data?: string }; result?: unknown }) => {
if (resp.error)
reject(
new Error(`${resp.error.message}${resp.error.data ? `: ${resp.error.data}` : ""}`),
);
else resolve(resp.result);
},
);
});
},
{ method, params },
Expand Down Expand Up @@ -1095,17 +1107,19 @@ export interface StableReleaseInfo {
export async function fetchLatestStableRelease(): Promise<StableReleaseInfo> {
const url = "https://api.jetkvm.com/releases?deviceId=e2e-test";
const body = await new Promise<string>((resolve, reject) => {
https.get(url, res => {
if (res.statusCode !== 200) {
reject(new Error(`Release API returned ${res.statusCode}`));
res.resume();
return;
}
let data = "";
res.on("data", chunk => (data += chunk));
res.on("end", () => resolve(data));
res.on("error", reject);
}).on("error", reject);
https
.get(url, res => {
if (res.statusCode !== 200) {
reject(new Error(`Release API returned ${res.statusCode}`));
res.resume();
return;
}
let data = "";
res.on("data", chunk => (data += chunk));
res.on("end", () => resolve(data));
res.on("error", reject);
})
.on("error", reject);
});

const json = JSON.parse(body);
Expand All @@ -1121,20 +1135,27 @@ export async function downloadFile(url: string, destPath: string): Promise<void>

await new Promise<void>((resolve, reject) => {
const request = (requestUrl: string) => {
proto.get(requestUrl, res => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
request(res.headers.location);
return;
}
if (res.statusCode !== 200) {
reject(new Error(`Download failed: ${res.statusCode} for ${requestUrl}`));
res.resume();
return;
}
res.pipe(file);
file.on("finish", () => file.close(() => resolve()));
res.on("error", reject);
}).on("error", reject);
proto
.get(requestUrl, res => {
if (
res.statusCode &&
res.statusCode >= 300 &&
res.statusCode < 400 &&
res.headers.location
) {
request(res.headers.location);
return;
}
if (res.statusCode !== 200) {
reject(new Error(`Download failed: ${res.statusCode} for ${requestUrl}`));
res.resume();
return;
}
res.pipe(file);
file.on("finish", () => file.close(() => resolve()));
res.on("error", reject);
})
.on("error", reject);
};
request(url);
});
Expand Down Expand Up @@ -1236,6 +1257,7 @@ declare global {
isWebRTCConnected: () => boolean;
isHidRpcReady: () => boolean;
isVideoStreamActive: () => boolean;
sendText: (text: string) => Promise<void>;
sendTerminalCommand: (command: string) => boolean;
isTerminalReady: () => boolean;
};
Expand Down
10 changes: 10 additions & 0 deletions ui/src/routes/devices.$id.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { m } from "@localizations/messages.js";
import { doRpcHidHandshake, useHidRpc } from "@hooks/useHidRpc";
import useKeyboard from "@hooks/useKeyboard";
import { registerTestHandlers, cleanupTestHooks } from "@/test/testHooks";
import { keyboards } from "@/keyboardLayouts";

export type AuthMode = "password" | "noPassword" | null;

Expand Down Expand Up @@ -880,6 +881,15 @@ export default function KvmIdRoute() {
getVideoElement: () => useVideoStore.getState().videoElement,
getKvmTerminal: () => useRTCStore.getState().terminalChannel,
getRpcDataChannel: () => useRTCStore.getState().rpcDataChannel,
getKeyboardLayout: () => {
const { keyboardLayout } = useSettingsStore.getState();
const isoCode = (keyboardLayout || "en-US").replace("en_US", "en-US");
return (
keyboards.find(kb => kb.isoCode === isoCode) ??
keyboards.find(kb => kb.isoCode === "en-US") ??
null
);
},
});
return cleanupTestHooks;
}, [handleKeyPress, handleAbsMouseMove]);
Expand Down
38 changes: 38 additions & 0 deletions ui/src/test/testHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
*/

import { KeyboardLedState, KeysDownState } from "@/hooks/stores";
import { KeyboardLayout } from "@/keyboardLayouts";
import { keys } from "@/keyboardMappings";

/** Internal handlers set by React components (prefixed with _ to indicate internal use) */
interface TestHooksInternal {
_handleKeyPress?: (key: number, press: boolean) => void;
_getKeyboardLayout?: () => KeyboardLayout | null;
_handleAbsMouseMove?: (x: number, y: number, buttons: number) => void;
_getKeyboardLedState?: () => KeyboardLedState;
_getKeysDownState?: () => KeysDownState;
Expand Down Expand Up @@ -51,6 +54,7 @@ export interface KvmTestHooks extends TestHooksInternal {
gridSize?: number,
) => number[] | null;
getVideoStreamDimensions: () => { width: number; height: number } | null;
sendText: (text: string) => Promise<void>;
isWebRTCConnected: () => boolean;
isHidRpcReady: () => boolean;
isVideoStreamActive: () => boolean;
Expand Down Expand Up @@ -90,6 +94,37 @@ export function initTestHooks(): void {
}
},

sendText: async (text: string): Promise<void> => {
const layout = hooks._getKeyboardLayout?.();
if (!layout) {
console.warn("[E2E] sendText: no keyboard layout");
return;
}
const sendPair = async (k: number, mods: number[]) => {
for (const m of mods) hooks._handleKeyPress?.(m, true);
hooks._handleKeyPress?.(k, true);
await new Promise(r => setTimeout(r, 20));
hooks._handleKeyPress?.(k, false);
for (const m of [...mods].reverse()) hooks._handleKeyPress?.(m, false);
await new Promise(r => setTimeout(r, 20));
};
for (const char of text) {
const keyprops = layout.chars[char.normalize("NFC")];
if (!keyprops?.key) continue;
const { key, shift, altRight, deadKey, accentKey } = keyprops;
if (accentKey) {
const mods = [
...(accentKey.shift ? [keys.ShiftLeft] : []),
...(accentKey.altRight ? [keys.AltRight] : []),
];
await sendPair(keys[String(accentKey.key) as keyof typeof keys], mods);
}
const mods = [...(shift ? [keys.ShiftLeft] : []), ...(altRight ? [keys.AltRight] : [])];
await sendPair(keys[String(key) as keyof typeof keys], mods);
if (deadKey) await sendPair(keys.Space, []);
}
},

sendJsonRpc: (
method: string,
params: Record<string, unknown>,
Expand Down Expand Up @@ -269,6 +304,7 @@ export function registerTestHandlers(handlers: {
getVideoElement: () => HTMLVideoElement | null;
getKvmTerminal: () => RTCDataChannel | null;
getRpcDataChannel: () => RTCDataChannel | null;
getKeyboardLayout: () => KeyboardLayout | null;
}): void {
if (!window.__kvmTestHooks) return;

Expand All @@ -283,6 +319,7 @@ export function registerTestHandlers(handlers: {
window.__kvmTestHooks._getVideoElement = handlers.getVideoElement;
window.__kvmTestHooks._getKvmTerminal = handlers.getKvmTerminal;
window.__kvmTestHooks._getRpcDataChannel = handlers.getRpcDataChannel;
window.__kvmTestHooks._getKeyboardLayout = handlers.getKeyboardLayout;
}

/**
Expand All @@ -293,6 +330,7 @@ export function cleanupTestHooks(): void {

window.__kvmTestHooks._handleKeyPress = undefined;
window.__kvmTestHooks._handleAbsMouseMove = undefined;
window.__kvmTestHooks._getKeyboardLayout = undefined;
window.__kvmTestHooks._getKeyboardLedState = undefined;
window.__kvmTestHooks._getKeysDownState = undefined;
window.__kvmTestHooks._getPeerConnectionState = undefined;
Expand Down
Loading