feat: basic automated e2e test (#1050)

This commit is contained in:
Adam Shiervani
2025-12-10 10:39:24 +01:00
committed by GitHub
parent 08f3a1e5a5
commit 428191b1d2
12 changed files with 551 additions and 22 deletions
+9
View File
@@ -51,6 +51,11 @@ TEST_DIRS := $(shell find . -name "*_test.go" -type f -exec dirname {} \; | sort
test:
go test ./...
test_e2e:
@read -p "Device IP: " device_ip; \
cd ui && npm install && npx playwright install --with-deps chromium && \
NODE_NO_WARNINGS=1 JETKVM_URL="http://$$device_ip" npm run test:e2e
lint:
go vet ./...
@@ -166,6 +171,8 @@ dev_release: git_check_dev
@read -p "Test on device before release? [y/N] " test_confirm; \
if [ "$$test_confirm" = "y" ]; then \
read -p "Device IP: " device_ip; \
echo "Installing Playwright dependencies..."; \
cd ui && npm ci && npx playwright install --with-deps chromium && cd ..; \
./scripts/test_release_on_device.sh "$$device_ip" bin/jetkvm_app test $(VERSION_DEV) || exit 1; \
fi
@echo "Uploading device app to R2..."
@@ -224,6 +231,8 @@ release: git_check_dev
@read -p "Test on device before release? [y/N] " test_confirm; \
if [ "$$test_confirm" = "y" ]; then \
read -p "Device IP: " device_ip; \
echo "Installing Playwright dependencies..."; \
cd ui && npm ci && npx playwright install --with-deps chromium && cd ..; \
./scripts/test_release_on_device.sh "$$device_ip" bin/jetkvm_app test $(VERSION) || exit 1; \
fi
@echo "Uploading device app to R2..."
+57 -18
View File
@@ -1,6 +1,9 @@
#!/bin/bash
set -e
# Get absolute path of this script for recursive calls
SCRIPT_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
DEVICE_IP="$1"
BINARY_PATH="$2"
ACTION="$3" # "deploy", "restore", or "test"
@@ -9,7 +12,7 @@ VERSION="$4" # required for "test" action
REMOTE_USER="root"
REMOTE_BIN_PATH="/userdata/jetkvm/bin"
REMOTE_UPDATE_PATH="/userdata/jetkvm"
SSH_OPTS="-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ConnectTimeout=10"
SSH_OPTS="-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o LogLevel=ERROR"
ssh_cmd() { ssh $SSH_OPTS "${REMOTE_USER}@${DEVICE_IP}" "$@"; }
@@ -29,36 +32,72 @@ case "$ACTION" in
ssh_cmd "reboot" || true
;;
test)
# Full interactive test flow
# Full automated test flow: version verification + E2E tests
[ -z "$VERSION" ] && { echo "Error: VERSION required for test action"; exit 1; }
echo ""
echo "Deploying $VERSION to $DEVICE_IP..."
"$0" "$DEVICE_IP" "$BINARY_PATH" deploy
# Get the repo root directory (parent of scripts/)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
echo ""
echo "═══════════════════════════════════════════════════════"
echo " Device is rebooting. Please verify:"
echo "═══════════════════════════════════════════════════════"
echo " Expected version: $VERSION"
echo " Settings page: http://$DEVICE_IP/settings/general"
echo ""
echo " Check that the version shown in the UI matches above."
echo " Testing $VERSION on $DEVICE_IP"
echo "═══════════════════════════════════════════════════════"
echo ""
read -p "Does the version match and binary work correctly? [y/n] " works
echo "Restoring device to previous binary..."
"$0" "$DEVICE_IP" "$BINARY_PATH" restore
echo "Step 1: Deploying $VERSION to $DEVICE_IP..."
"$SCRIPT_PATH" "$DEVICE_IP" "$BINARY_PATH" deploy
if [ "$works" != "y" ]; then
echo "Test failed."
echo ""
echo "Step 2: Waiting for device to come back online..."
sleep 10 # Initial wait for reboot
for i in {1..30}; do
if curl -sf "http://$DEVICE_IP/device/status" > /dev/null 2>&1; then
echo "Device is online"
break
fi
echo "Waiting... ($i/30)"
sleep 2
done
# Extra wait for services to fully start
sleep 5
echo ""
echo "Step 3: Verifying deployed version..."
# Get version from Prometheus metrics endpoint
DEPLOYED_VERSION=$(curl -sf "http://$DEVICE_IP/metrics" | grep 'jetkvm_build_info' | sed -n 's/.*version="\([^"]*\)".*/\1/p')
echo " Expected: $VERSION"
echo " Deployed: $DEPLOYED_VERSION"
if [ "$DEPLOYED_VERSION" != "$VERSION" ]; then
echo ""
echo "❌ Version mismatch! Restoring previous binary..."
"$SCRIPT_PATH" "$DEVICE_IP" "$BINARY_PATH" restore
exit 1
fi
echo "Test passed."
echo " ✓ Version matches"
echo ""
echo "Step 4: Running E2E tests..."
E2E_RESULT=0
cd "$REPO_ROOT/ui" && NODE_NO_WARNINGS=1 JETKVM_URL="http://$DEVICE_IP" npm run test:e2e || E2E_RESULT=$?
echo ""
echo "Step 5: Restoring device to previous binary..."
"$SCRIPT_PATH" "$DEVICE_IP" "$BINARY_PATH" restore
if [ $E2E_RESULT -ne 0 ]; then
echo ""
echo "❌ E2E tests failed."
exit 1
fi
echo ""
echo "✅ All tests passed for $VERSION"
;;
*)
echo "Usage: $0 <device_ip> <binary_path> <deploy|restore|test> [version]"
echo "Usage: $SCRIPT_PATH <device_ip> <binary_path> <deploy|restore|test> [version]"
exit 1
;;
esac
+1 -1
View File
@@ -6,7 +6,7 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
test-results
node_modules
dist
dist-ssr
+163
View File
@@ -0,0 +1,163 @@
import { Page, expect } from "@playwright/test";
/**
* USB HID Key Codes
*/
export const HID_KEY = {
SPACE: 0x2c, // 44
CAPS_LOCK: 0x39, // 57
NUM_LOCK: 0x53, // 83
} as const;
/**
* Keyboard LED state interface (matches KeyboardLedState from stores.ts)
*/
export interface KeyboardLedState {
num_lock: boolean;
caps_lock: boolean;
scroll_lock: boolean;
compose: boolean;
kana: boolean;
shift: boolean;
}
/**
* Wait for the WebRTC connection to be established and HID RPC to be ready.
* This polls the test hooks until both conditions are met.
*
* @param page - Playwright page object
* @param timeout - Maximum time to wait in milliseconds (default: 30000)
*/
export async function waitForWebRTCReady(page: Page, timeout = 30000): Promise<void> {
await expect
.poll(
async () => {
const status = await page.evaluate(() => {
const hooks = window.__kvmTestHooks;
if (!hooks) {
return { hooks: false, webrtc: false, hid: false };
}
return {
hooks: true,
webrtc: hooks.isWebRTCConnected(),
hid: hooks.isHidRpcReady(),
};
});
return status.hooks && status.webrtc && status.hid;
},
{
message: "Waiting for WebRTC connection and HID RPC to be ready",
timeout,
intervals: [500, 1000, 2000],
},
)
.toBe(true);
}
/**
* Wait for video stream to be active.
*
* @param page - Playwright page object
* @param timeout - Maximum time to wait in milliseconds (default: 30000)
*/
export async function waitForVideoStream(page: Page, timeout = 30000): Promise<void> {
await expect
.poll(
async () => page.evaluate(() => window.__kvmTestHooks?.isVideoStreamActive()),
{
message: "Waiting for video stream to be active",
timeout,
intervals: [500, 1000, 2000],
},
)
.toBe(true);
}
/**
* Send a keypress event via the test hooks.
*
* @param page - Playwright page object
* @param keyCode - USB HID key code
* @param press - true for key down, false for key up
*/
export async function sendKeypress(page: Page, keyCode: number, press: boolean): Promise<void> {
await page.evaluate(
({ key, isPress }) => {
const hooks = window.__kvmTestHooks;
if (!hooks) throw new Error("Test hooks not available");
hooks.sendKeypress(key, isPress);
},
{ key: keyCode, isPress: press },
);
}
/**
* Send a complete key tap (press + release) with a small delay between.
*
* @param page - Playwright page object
* @param keyCode - USB HID key code
* @param holdMs - Time to hold the key in milliseconds (default: 50)
*/
export async function tapKey(page: Page, keyCode: number, holdMs = 50): Promise<void> {
await sendKeypress(page, keyCode, true);
await page.waitForTimeout(holdMs);
await sendKeypress(page, keyCode, false);
}
/**
* Get the current keyboard LED state.
*
* @param page - Playwright page object
* @returns The current LED state or null if not available
*/
export async function getLedState(page: Page): Promise<KeyboardLedState | null> {
return page.evaluate(() => {
const hooks = window.__kvmTestHooks;
if (!hooks) return null;
return hooks.getKeyboardLedState();
});
}
/**
* Wait for a specific LED state to change.
* Useful for verifying round-trip after sending a key.
*
* @param page - Playwright page object
* @param ledName - Name of the LED to check (e.g., 'caps_lock', 'num_lock')
* @param expectedValue - Expected boolean value
* @param timeout - Maximum time to wait in milliseconds (default: 5000)
*/
export async function waitForLedState(
page: Page,
ledName: keyof KeyboardLedState,
expectedValue: boolean,
timeout = 5000,
): Promise<void> {
await expect
.poll(
async () => {
const state = await getLedState(page);
return state?.[ledName];
},
{
message: `Waiting for ${ledName} to be ${expectedValue}`,
timeout,
intervals: [100, 200, 500],
},
)
.toBe(expectedValue);
}
// TypeScript declarations for the test hooks on window
declare global {
interface Window {
__kvmTestHooks?: {
getKeyboardLedState: () => KeyboardLedState | null;
getKeysDownState: () => { modifier: number; keys: number[] } | null;
sendKeypress: (key: number, press: boolean) => void;
isWebRTCConnected: () => boolean;
isHidRpcReady: () => boolean;
isVideoStreamActive: () => boolean;
};
}
}
+60
View File
@@ -0,0 +1,60 @@
import { test, expect } from "@playwright/test";
import {
waitForWebRTCReady,
waitForVideoStream,
getLedState,
tapKey,
waitForLedState,
HID_KEY,
KeyboardLedState,
} from "./helpers";
// Parameterized test data for LED round-trip tests
const LED_TESTS = [
{ name: "CAPS_LOCK", key: HID_KEY.CAPS_LOCK, led: "caps_lock" as keyof KeyboardLedState },
{ name: "NUM_LOCK", key: HID_KEY.NUM_LOCK, led: "num_lock" as keyof KeyboardLedState },
] as const;
test.describe("LED Round-Trip Tests", () => {
test.beforeEach(async ({ page }) => {
// Navigate to the device page (on-device mode uses "/" as the device route)
await page.goto("/");
// Wait for WebRTC connection to be established
await waitForWebRTCReady(page);
});
for (const { name, key, led } of LED_TESTS) {
test(`${name} round-trip toggles LED state`, async ({ page }) => {
// Get initial state
const initialState = await getLedState(page);
expect(initialState).not.toBeNull();
const initialValue = initialState![led];
console.log(`Initial ${name} state: ${initialValue}`);
// Toggle and verify
await tapKey(page, key);
await waitForLedState(page, led, !initialValue);
expect((await getLedState(page))![led]).toBe(!initialValue);
console.log(`New ${name} state: ${!initialValue}`);
// Restore and verify
await tapKey(page, key);
await waitForLedState(page, led, initialValue);
expect((await getLedState(page))![led]).toBe(initialValue);
});
}
test("video stream is active", async ({ page }) => {
// Send a few SPACE keys to wake display if screensaver/sleep is active
for (let i = 0; i < 3; i++) {
await tapKey(page, HID_KEY.SPACE);
await page.waitForTimeout(200);
}
await waitForVideoStream(page);
const isActive = await page.evaluate(() => window.__kvmTestHooks?.isVideoStreamActive());
expect(isActive).toBe(true);
});
});
+82
View File
@@ -52,10 +52,12 @@
"@inlang/plugin-m-function-matcher": "^2.1.0",
"@inlang/plugin-message-format": "^4.0.0",
"@inlang/sdk": "^2.4.9",
"@playwright/test": "^1.49.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/node": "^24.10.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@types/semver": "^7.7.1",
@@ -1278,6 +1280,22 @@
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@playwright/test": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@react-aria/focus": {
"version": "3.21.2",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz",
@@ -2384,6 +2402,16 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/react": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
@@ -6190,6 +6218,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -7461,6 +7536,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unplugin": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.10.tgz",
+4 -1
View File
@@ -24,7 +24,8 @@
"i18n:audit": "npm run i18n:find-dupes && npm run i18n:find-excess && npm run i18n:find-unused",
"i18n:find-excess": "python3 ./tools/find_excess_messages.py",
"i18n:find-unused": "python3 ./tools/find_unused_messages.py",
"i18n:find-dupes": "python3 ./tools/find_duplicate_translations.py"
"i18n:find-dupes": "python3 ./tools/find_duplicate_translations.py",
"test:e2e": "playwright test"
},
"dependencies": {
"@headlessui/react": "^2.2.9",
@@ -71,10 +72,12 @@
"@inlang/plugin-m-function-matcher": "^2.1.0",
"@inlang/plugin-message-format": "^4.0.0",
"@inlang/sdk": "^2.4.9",
"@playwright/test": "^1.49.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/node": "^24.10.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@types/semver": "^7.7.1",
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "@playwright/test";
if (!process.env.JETKVM_URL) {
throw new Error("JETKVM_URL environment variable is required");
}
export default defineConfig({
testDir: "./e2e",
timeout: 60000,
workers: 1,
reporter: "list",
use: {
baseURL: process.env.JETKVM_URL,
trace: "retain-on-failure",
video: "retain-on-failure",
screenshot: "only-on-failure",
},
});
+4
View File
@@ -11,6 +11,7 @@ import {
import "./index.css";
import { ExclamationTriangleIcon } from "@heroicons/react/16/solid";
import { initTestHooks } from "@/test/testHooks";
import { CLOUD_API, CLOUD_ENABLE_VERSIONED_UI, DEVICE_API } from "@/ui.config";
import api from "@/api";
import Root from "@/root";
@@ -55,6 +56,9 @@ const SettingsMacrosEditRoute = lazy(() => import("@routes/devices.$id.settings.
export const isOnDevice = import.meta.env.MODE === "device";
export const isInCloud = !isOnDevice;
// Initialize E2E test hooks (safe to call in all environments)
initTestHooks();
export async function checkCloudAuth() {
const res = await fetch(`${CLOUD_API}/me`, {
mode: "cors",
+19
View File
@@ -54,6 +54,8 @@ import {
import { FeatureFlagProvider } from "@providers/FeatureFlagProvider";
import { m } from "@localizations/messages.js";
import { doRpcHidHandshake } from "@hooks/useHidRpc";
import useKeyboard from "@hooks/useKeyboard";
import { registerTestHandlers, cleanupTestHooks } from "@/test/testHooks";
export type AuthMode = "password" | "noPassword" | null;
@@ -629,6 +631,23 @@ export default function KvmIdRoute() {
const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled);
const { setFailsafeMode } = useFailsafeModeStore();
// Keyboard handler for E2E tests
const { handleKeyPress } = useKeyboard();
// Register E2E test hooks
useEffect(() => {
registerTestHandlers({
handleKeyPress,
getKeyboardLedState: () => useHidStore.getState().keyboardLedState,
getKeysDownState: () => useHidStore.getState().keysDownState,
getPeerConnectionState: () => useRTCStore.getState().peerConnectionState,
getRpcHidProtocolVersion: () => useRTCStore.getState().rpcHidProtocolVersion,
getMediaStream: () => useRTCStore.getState().mediaStream,
getHdmiState: () => useVideoStore.getState().hdmiState,
});
return cleanupTestHooks;
}, [handleKeyPress]);
const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation();
+130
View File
@@ -0,0 +1,130 @@
/**
* E2E Test Hooks
*
* This module exposes test hooks on window.__kvmTestHooks for Playwright E2E tests.
* The hooks are only active when the page has window.__E2E_TEST__ set to true.
*
* Usage in tests:
* await page.evaluate(() => window.__E2E_TEST__ = true);
* await page.goto('/devices/local');
* const ledState = await page.evaluate(() => window.__kvmTestHooks?.getKeyboardLedState());
*/
import { KeyboardLedState, KeysDownState } from "@/hooks/stores";
export interface KvmTestHooks {
/** Get current keyboard LED state (caps lock, num lock, etc.) */
getKeyboardLedState: () => KeyboardLedState | null;
/** Get current keys down state */
getKeysDownState: () => KeysDownState | null;
/** Send a keypress event (key: USB HID keycode, press: true=down, false=up) */
sendKeypress: (key: number, press: boolean) => void;
/** Check if WebRTC peer connection is connected */
isWebRTCConnected: () => boolean;
/** Check if HID RPC channel is ready */
isHidRpcReady: () => boolean;
/** Check if video stream is active */
isVideoStreamActive: () => boolean;
}
/** Internal handler storage type */
interface TestHooksInternal {
handleKeyPress?: (key: number, press: boolean) => void;
getKeyboardLedState?: () => KeyboardLedState;
getKeysDownState?: () => KeysDownState;
getPeerConnectionState?: () => RTCPeerConnectionState | null;
getRpcHidProtocolVersion?: () => number | null;
getMediaStream?: () => MediaStream | null;
getHdmiState?: () => string;
}
declare global {
interface Window {
__E2E_TEST__?: boolean;
__kvmTestHooks?: KvmTestHooks;
__kvmTestHooksInternal?: TestHooksInternal;
}
}
/**
* Initialize test hooks on the window object.
* Call this early in the app lifecycle.
*/
export function initTestHooks(): void {
if (typeof window === "undefined") return;
// Initialize internal hooks storage
window.__kvmTestHooksInternal = {};
// Expose the public API
window.__kvmTestHooks = {
getKeyboardLedState: () => {
return window.__kvmTestHooksInternal?.getKeyboardLedState?.() ?? null;
},
getKeysDownState: () => {
return window.__kvmTestHooksInternal?.getKeysDownState?.() ?? null;
},
sendKeypress: (key: number, press: boolean) => {
const handler = window.__kvmTestHooksInternal?.handleKeyPress;
if (handler) {
handler(key, press);
} else {
console.warn("[E2E] sendKeypress called but no handler registered");
}
},
isWebRTCConnected: () => {
const state = window.__kvmTestHooksInternal?.getPeerConnectionState?.();
return state === "connected";
},
isHidRpcReady: () => {
const version = window.__kvmTestHooksInternal?.getRpcHidProtocolVersion?.();
return version !== null && version !== undefined;
},
isVideoStreamActive: () => {
const hdmiState = window.__kvmTestHooksInternal?.getHdmiState?.();
if (hdmiState !== "ready") return false;
const stream = window.__kvmTestHooksInternal?.getMediaStream?.();
if (!stream) return false;
const videoTracks = stream.getVideoTracks();
return videoTracks.length > 0 && videoTracks[0].readyState === "live";
},
};
console.log("[E2E] Test hooks initialized");
}
/**
* Register all test handlers at once.
* Call this from the device route component.
*/
export function registerTestHandlers(handlers: {
handleKeyPress: (key: number, press: boolean) => void;
getKeyboardLedState: () => KeyboardLedState;
getKeysDownState: () => KeysDownState;
getPeerConnectionState: () => RTCPeerConnectionState | null;
getRpcHidProtocolVersion: () => number | null;
getMediaStream: () => MediaStream | null;
getHdmiState: () => string;
}): void {
if (window.__kvmTestHooksInternal) {
Object.assign(window.__kvmTestHooksInternal, handlers);
}
}
/**
* Cleanup test hooks when component unmounts.
*/
export function cleanupTestHooks(): void {
window.__kvmTestHooksInternal = {};
}
+4 -2
View File
@@ -8,6 +8,8 @@
"strict": true
},
"include": [
"vite.config.ts"
"vite.config.ts",
"playwright.config.ts",
"e2e/**/*.ts"
]
}
}