Files
kvm/ui/e2e/settings-local-auth.spec.ts
Adam Shiervani 15dc380062 fix: auto-recover USB gadget when host power-cycles (#1297)
* fix: auto-recover USB gadget when host reconnects (#128)

When the USB host reboots or disconnects, the UDC state becomes
"not attached" and never recovers. Add automatic recovery that detects
this state and rebinds the USB gadget, with rate limiting to avoid
thrashing. Also refactor keyboard HID file handling to support
force-reopen after rebind.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: move USB recovery logic to internal/usbgadget package

Extract ShouldAttemptUSBRecovery and its retry interval constant into
the internal/usbgadget package so the logic is testable without
importing the top-level kvm package. Reset the recovery timer in
updateUsbRelatedConfig to prevent auto-recovery from interfering
during the host's USB re-enumeration window after a deliberate
config change.

Made-with: Cursor

* feat(e2e): add JSON-RPC data channel support to test hooks

Expose the WebRTC RPC data channel through test hooks and add a
sendJsonRpc helper that sends a JSON-RPC request over the channel
and resolves via callback with timeout handling. This enables e2e
tests to invoke backend RPC methods directly.

Made-with: Cursor

* refactor(e2e): restructure tests with remote-agent suite

Move device-level e2e tests (config-reset, EDID, HTTPS, HDMI sleep,
LED, mouse, USB attach timing, USB device) into a consolidated
remote-agent suite that runs through a Go-based agent binary. Add a
separate Playwright project for remote-agent tests.

- Remove standalone spec files now covered by ra-all.spec.ts
- Rename login-rate-limit to zz-login-rate-limit so it runs last
  (avoids needing a post-test reboot to clear rate-limit state)
- Reorder welcome-password tests so validation runs first, reusing
  the onboarding state and saving an SSH reset cycle

Made-with: Cursor

* refactor(e2e): clean up test helpers and reduce duplication

Consolidate all test helpers into a single helpers.ts file, removing
the separate ota-helpers.ts. This gives every test file a single import
source and eliminates duplicated code across the test suite.

Key changes:
- Merge ota-helpers.ts into helpers.ts (mock server, binary deployment,
  device config, env var validation, triggerUpdate, withTempSignature)
- Remove duplicated rpc/restartAppViaSSH/waitForDeviceReady functions
  from ra-all.spec.ts in favour of shared imports
- Extract loginAndOpenSettings helper in settings-local-auth tests
- Extract getOTAEnvVars, toPreReleaseVersion, triggerUpdate, and
  withTempSignature to reduce boilerplate across OTA tests
- Remove unused verifyMouseWorks function and dead variables
- Strip redundant JSDoc that just restated type signatures
- Remove duplicated per-project use config from playwright.config.ts
  (already inherited from top-level)
- Convert dynamic imports in sshExec to top-level imports

Made-with: Cursor

* refactor(e2e): move binary deployment into Playwright globalSetup

Replace the shell-script deployment logic with Playwright's
globalSetup/globalTeardown hooks. When BASELINE_BINARY_PATH is set,
globalSetup deploys the binary, resets device config, reboots, and
captures pre-test logs. globalTeardown captures post-test logs.

This keeps the deployment lifecycle inside Playwright where it belongs,
and reduces test_core_e2e.sh to a thin wrapper that sets env vars.

Made-with: Cursor

* fix: retry HID file reopen after USB gadget rebind

After rebinding the DWC3 USB controller, the kernel needs a moment to
create the /dev/hidg* device nodes. The previous code attempted to
reopen the keyboard HID file immediately after rebind, which raced
with the kernel and failed with "no such device or address".

Add a retry loop (up to 10 attempts, 200ms apart) to wait for the
device nodes to appear before reopening the keyboard HID file.

Made-with: Cursor

* fix: harden USB gadget recovery after UDC unbind

Reset stale HID gadget handles after rebind, suppress transient HID-open errors during detach windows, and fall back to full gadget reconfiguration when simple UDC rebind does not restore keyboard HID promptly. Strengthen the remote-agent USB recovery E2E to verify both keyboard and mouse input recover after unbind with retry tolerance for host-side input node churn.

Made-with: Cursor

* refactor: simplify USB HID error handling and reduce hot-path overhead

- Use errors.Is with syscall.Errno instead of string matching in
  IsHIDTemporarilyUnavailableError (robust, zero-alloc)
- Cache USB state in usbReadyForHidReports instead of reading sysfs
  on every HID report
- Extract rpcHidReport wrapper to deduplicate 5 rpc*Report functions
- Fix openWithTimeout goroutine/fd leak on timeout
- Add USBStateNotAttached/USBStateUnknown constants, replace literals
- Deduplicate discoverJetKVMDevices by delegating to listInputDevices

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: run remote-agent e2e tests when JETKVM_REMOTE_HOST is provided

Made-with: Cursor

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 12:54:20 +01:00

82 lines
2.8 KiB
TypeScript

import { test, expect, type Page } from "@playwright/test";
import {
ensureLocalAuthMode,
openAccessSettings,
enablePasswordFromSettings,
changePasswordFromSettings,
disablePasswordFromSettings,
loginLocal,
} from "./helpers";
const TEST_PASSWORD = "TestPassword123";
const NEW_PASSWORD = "NewPassword456";
async function loginAndOpenSettings(page: Page, password: string) {
await page.goto("/");
await page.waitForLoadState("networkidle");
if (page.url().includes("/login")) {
await loginLocal(page, password);
}
await openAccessSettings(page);
}
test.describe("Settings Local Auth Tests", () => {
test.setTimeout(180000);
test.describe.configure({ mode: "serial" });
test.beforeAll(async ({ browser }) => {
const context = await browser.newContext({ baseURL: process.env.JETKVM_URL });
const page = await context.newPage();
try {
await ensureLocalAuthMode(page, { mode: "noPassword" });
} finally {
await page.close();
await context.close();
}
});
test("password minimum length validation in settings create modal", async ({ page }) => {
await openAccessSettings(page);
await enablePasswordFromSettings(page, "short", "short", false);
const errorMessage = page.locator(".text-red-500").first();
await expect(errorMessage).toBeVisible({ timeout: 5000 });
const errorText = await errorMessage.textContent();
expect(errorText).toMatch(/at least 8 characters/i);
});
test("create password from settings when in noPassword mode", async ({ page }) => {
await openAccessSettings(page);
await enablePasswordFromSettings(page, TEST_PASSWORD);
const disableButton = page.getByRole("button").filter({ hasText: /Disable Protection/i });
await expect(disableButton).toBeVisible({ timeout: 5000 });
});
test("password minimum length validation in settings update modal", async ({ page }) => {
await loginAndOpenSettings(page, TEST_PASSWORD);
await changePasswordFromSettings(page, TEST_PASSWORD, "short", "short", false);
const errorMessage = page.locator(".text-red-500").first();
await expect(errorMessage).toBeVisible({ timeout: 5000 });
const errorText = await errorMessage.textContent();
expect(errorText).toMatch(/at least 8 characters/i);
});
test("update password from settings", async ({ page }) => {
await loginAndOpenSettings(page, TEST_PASSWORD);
await changePasswordFromSettings(page, TEST_PASSWORD, NEW_PASSWORD);
expect(page.url()).toContain("/settings/access");
});
test("delete password from settings", async ({ page }) => {
await loginAndOpenSettings(page, NEW_PASSWORD);
await disablePasswordFromSettings(page, NEW_PASSWORD);
const enableButton = page.getByRole("button").filter({ hasText: /Enable Password/i });
await expect(enableButton).toBeVisible({ timeout: 5000 });
});
});