mirror of
https://github.com/jetkvm/kvm.git
synced 2026-05-21 05:20:35 +00:00
feat: basic automated e2e test (#1050)
This commit is contained in:
@@ -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..."
|
||||
|
||||
@@ -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
@@ -6,7 +6,7 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
test-results
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Generated
+82
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 = {};
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
"strict": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
"vite.config.ts",
|
||||
"playwright.config.ts",
|
||||
"e2e/**/*.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user