diff --git a/Makefile b/Makefile index 4167a4ee..40aa4f06 100644 --- a/Makefile +++ b/Makefile @@ -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..." diff --git a/scripts/test_release_on_device.sh b/scripts/test_release_on_device.sh index 66ad4f52..a8c9caef 100755 --- a/scripts/test_release_on_device.sh +++ b/scripts/test_release_on_device.sh @@ -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 [version]" + echo "Usage: $SCRIPT_PATH [version]" exit 1 ;; esac diff --git a/ui/.gitignore b/ui/.gitignore index a547bf36..720a9ef8 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -6,7 +6,7 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* - +test-results node_modules dist dist-ssr diff --git a/ui/e2e/helpers.ts b/ui/e2e/helpers.ts new file mode 100644 index 00000000..0d06ec43 --- /dev/null +++ b/ui/e2e/helpers.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + }; + } +} diff --git a/ui/e2e/led-roundtrip.spec.ts b/ui/e2e/led-roundtrip.spec.ts new file mode 100644 index 00000000..1db40052 --- /dev/null +++ b/ui/e2e/led-roundtrip.spec.ts @@ -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); + }); +}); diff --git a/ui/package-lock.json b/ui/package-lock.json index c4e1c96c..7f6c902c 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/package.json b/ui/package.json index fc4ad519..104cbddc 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 00000000..9b271547 --- /dev/null +++ b/ui/playwright.config.ts @@ -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", + }, +}); diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 28607985..1977bbac 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -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", diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 043125c0..48b36219 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -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(); diff --git a/ui/src/test/testHooks.ts b/ui/src/test/testHooks.ts new file mode 100644 index 00000000..18b78581 --- /dev/null +++ b/ui/src/test/testHooks.ts @@ -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 = {}; +} diff --git a/ui/tsconfig.node.json b/ui/tsconfig.node.json index d5ed4eaa..7f2aa3cd 100644 --- a/ui/tsconfig.node.json +++ b/ui/tsconfig.node.json @@ -8,6 +8,8 @@ "strict": true }, "include": [ - "vite.config.ts" + "vite.config.ts", + "playwright.config.ts", + "e2e/**/*.ts" ] -} \ No newline at end of file +}