mirror of
https://github.com/jetkvm/kvm.git
synced 2026-05-21 05:20:35 +00:00
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:
+58
-36
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user