fix: expose sendText on test hooks for layout-aware HID text input (#787)

Add sendText(text) to window.__kvmTestHooks for Playwright E2E tests.
This bridges the keyboard-layout-aware character-to-HID-keystroke conversion
(already in PasteModal) to the test hooks API, since Puppeteer's keyboard.type()
bypasses the WebRTC HID data channel.

- Add _getKeyboardLayout to TestHooksInternal, populated from registerTestHandlers
- Implement sendText() in initTestHooks() using layout.chars lookup with shift/altRight/deadKey/accentKey handling
- Add getKeyboardLayout handler in devices.$id.tsx route
- Add sendText() helper in e2e/helpers.ts
This commit is contained in:
Adam Shiervani
2026-03-25 11:21:10 +01:00
parent 66b71385ad
commit 2d491b075b
3 changed files with 106 additions and 36 deletions
+58 -36
View File
@@ -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);
@@ -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",
@@ -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") {
@@ -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;
@@ -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 },
@@ -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);
@@ -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);
});
@@ -1236,6 +1257,7 @@ declare global {
isWebRTCConnected: () => boolean;
isHidRpcReady: () => boolean;
isVideoStreamActive: () => boolean;
sendText: (text: string) => Promise<void>;
sendTerminalCommand: (command: string) => boolean;
isTerminalReady: () => boolean;
};
+10
View File
@@ -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;
@@ -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]);
+38
View File
@@ -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;
@@ -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;
@@ -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>,
@@ -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;
@@ -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;
}
/**
@@ -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;