From 64a514520cc83de49978ad02fb8f3002e3a5364c Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Wed, 19 Nov 2025 15:28:12 +0100 Subject: [PATCH] feat: add e2e test for OTA process --- .gitignore | 9 +- ui/.gitignore | 7 + ui/package-lock.json | 64 +++++++++ ui/package.json | 3 +- ui/playwright.config.ts | 82 +++++++++++ ui/src/components/StatusCards.tsx | 2 +- ui/tests/helper.ts | 19 +++ ui/tests/ota.spec.ts | 220 ++++++++++++++++++++++++++++++ ui/tsconfig.json | 3 +- 9 files changed, 405 insertions(+), 4 deletions(-) create mode 100644 ui/playwright.config.ts create mode 100644 ui/tests/helper.ts create mode 100644 ui/tests/ota.spec.ts diff --git a/.gitignore b/.gitignore index 1691153c..ce0b1bea 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,11 @@ node_modules #internal/native/include #internal/native/lib -ui/reports \ No newline at end of file +ui/reports + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/ui/.gitignore b/ui/.gitignore index a547bf36..2e5968d2 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -22,3 +22,10 @@ dist-ssr *.njsproj *.sln *.sw? + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/ui/package-lock.json b/ui/package-lock.json index 437b0c93..ab5d4708 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -50,6 +50,7 @@ "@inlang/plugin-m-function-matcher": "^2.1.0", "@inlang/plugin-message-format": "^4.0.0", "@inlang/sdk": "^2.4.9", + "@playwright/test": "^1.56.1", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/postcss": "^4.1.17", "@tailwindcss/typography": "^0.5.19", @@ -1276,6 +1277,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.1" + }, + "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", @@ -6188,6 +6205,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "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", diff --git a/ui/package.json b/ui/package.json index 0f4d7f64..51981118 100644 --- a/ui/package.json +++ b/ui/package.json @@ -95,6 +95,7 @@ "tailwindcss": "^4.1.17", "typescript": "^5.9.3", "vite": "^7.1.12", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^5.1.4", + "@playwright/test": "^1.56.1" } } diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 00000000..1f1b4a61 --- /dev/null +++ b/ui/playwright.config.ts @@ -0,0 +1,82 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: 1, + timeout: 10 * 60 * 1000, // 10 minutes + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on', + video: 'on', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + // Headless must be disabled otherwise WebRTC will not work properly + use: { ...devices['Desktop Chrome'], headless: false }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/ui/src/components/StatusCards.tsx b/ui/src/components/StatusCards.tsx index 8cbe9f38..3404b983 100644 --- a/ui/src/components/StatusCards.tsx +++ b/ui/src/components/StatusCards.tsx @@ -26,7 +26,7 @@ export default function StatusCard({ ) : null}
-
+
{title}
diff --git a/ui/tests/helper.ts b/ui/tests/helper.ts new file mode 100644 index 00000000..a9b44339 --- /dev/null +++ b/ui/tests/helper.ts @@ -0,0 +1,19 @@ +import { Page } from "@playwright/test"; + +import { m } from "../localization/paraglide/messages.js"; + +type I18nKey = keyof typeof m; +type I18nOptions = Record; + +export const translate = (i18nKey: I18nKey, options?: I18nOptions) => ( + m[i18nKey as keyof typeof m] as (options?: I18nOptions) => string +)(options); + +export const getByText = (page: Page, i18nKey: I18nKey, options?: I18nOptions) => ( + page.getByText(translate(i18nKey, options)) +); + +export const getByRole = (page: Page, role: Parameters[0], i18nKey: I18nKey) => ( + page.getByRole(role, { name: translate(i18nKey) }) +); + diff --git a/ui/tests/ota.spec.ts b/ui/tests/ota.spec.ts new file mode 100644 index 00000000..33adf4ab --- /dev/null +++ b/ui/tests/ota.spec.ts @@ -0,0 +1,220 @@ +import { test, expect } from '@playwright/test'; + +import { m } from '../localization/paraglide/messages'; + +import { getByRole, getByText } from './helper'; + +const TARGET_DEVICE_IP = "192.168.2.4" + +test('upgrade process', async ({ page }) => { + await page.goto(`http://${TARGET_DEVICE_IP}`); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/JetKVM/); + + // Except ICE gathering completed or JetKVM device connected + await expect(getByText(page, 'ice_gathering_completed').or(getByText(page, 'peer_connection_connected'))).toBeVisible(); + + // Except No HDMI signal detected, as the device is not connected to the HDMI port + await expect(getByText(page, 'video_overlay_no_hdmi_signal')).toBeVisible(); + + // Emulate upgrade process + await getByRole(page, 'button', 'action_bar_settings').click(); + await expect(getByText(page, 'general_page_description')).toBeVisible(); + + const btnCheckForUpdates = getByRole(page, 'button', 'general_check_for_updates'); + await expect(btnCheckForUpdates).toBeVisible(); + await btnCheckForUpdates.click(); + + // if System is to update, we'll then skip the update process and go to the next step + if (await getByText(page, 'general_update_up_to_date_title').isVisible()) { + test.skip(true, 'System is up to date, skipping update process'); + return; + } + + // LoadingState -> Checking for updates... + await expect(getByText(page, 'general_update_checking_title')).toBeVisible(); + + // UpdateAvailableState -> Prompt to update the device + await expect(getByText(page, 'general_update_available_title')).toBeVisible(); + const btnUpdateNow = getByRole(page, 'button', 'general_update_now_button'); + await expect(btnUpdateNow).toBeVisible(); + await btnUpdateNow.click(); + + // LoadingState -> Updating your device... + await expect(getByText(page, 'general_update_updating_title')).toBeVisible(); + const update_type = m.general_update_application_type(); + await expect(getByText(page, 'general_update_status_downloading', { update_type })).toBeVisible(); + await expect(getByText(page, 'general_update_status_verifying', { update_type })).toBeVisible(); + await expect(getByText(page, 'general_update_status_installing', { update_type })).toBeVisible(); + await expect(getByText(page, 'general_update_status_awaiting_reboot')).toBeVisible(); + + await page.waitForTimeout(30000); + // await expect(page.getByText('Update available')).toBeVisible(); + + // await page.getByRole('button', { name: 'Update now' }).click(); + + // await expect(page.getByText('Updating...')).toBeVisible(); + +}); + +test('downgrade process', async ({ page }) => { + await page.goto(`http://${TARGET_DEVICE_IP}`); + + // Expect a title "to contain" a substring. + await expect( + page, + 'Title should include JetKVM', + ).toHaveTitle(/JetKVM/); + + // Except ICE gathering completed or JetKVM device connected + await expect( + getByText(page, 'ice_gathering_completed').or(getByText(page, 'peer_connection_connected')), + 'should be ICE gathering completed or JetKVM device connected', + ).toBeVisible(); + + // Except No HDMI signal detected, as the device is not connected to the HDMI port + await expect( + getByText(page, 'video_overlay_no_hdmi_signal'), + 'should be visible when no HDMI signal is detected', + ).toBeVisible(); + + // Emulate upgrade process + await getByRole(page, 'button', 'action_bar_settings').click(); + await expect( + getByText(page, 'general_page_description'), + 'should be visible when general page description is visible', + ).toBeVisible(); + + // Go to Advanced tab + const btnAdvanced = getByRole(page, 'link', 'settings_advanced'); + await expect( + btnAdvanced, + 'should be visible when advanced link is visible', + ).toBeVisible(); + await btnAdvanced.click(); + + // Now we're on the Advanced tab + await expect( + getByText(page, 'advanced_version_update_title'), + 'should be visible when advanced version update title is visible', + ).toBeVisible(); + + // choose the System only option + const whatToUpdateLabel = page.locator('label', { hasText: m.advanced_version_update_target_label() }); + await expect( + whatToUpdateLabel, + 'should be visible when what to update label is visible', + ).toBeVisible(); + const whatToUpdateSelect = page.locator('select'); + await expect( + whatToUpdateSelect, + 'should be visible when what to update select is visible', + ).toBeVisible(); + await whatToUpdateSelect.selectOption('system'); + + // now, make sure the system version input is visible + const systemVersionLabel = page.locator( + 'div', + { hasText: m.advanced_version_update_system_label(), hasNotText: m.advanced_version_update_target_label() }, + ); + await expect( + systemVersionLabel, + 'SystemVersionLabel should be visible', + ).toBeVisible(); + const systemVersionInput = systemVersionLabel.getByRole('textbox'); + await expect( + systemVersionInput, + 'SystemVersionInput should be visible', + ).toBeVisible(); + await systemVersionInput.fill('0.2.7'); + + // acknowledge the version change + const versionChangeAcknowledgedLabel = page.locator( + 'label', + { hasText: "I understand version changes may break my device and require factory reset" }, + ); + await expect( + versionChangeAcknowledgedLabel, + ).toBeVisible(); + const versionChangeAcknowledgedCheckbox = versionChangeAcknowledgedLabel.getByRole('checkbox'); + await expect(versionChangeAcknowledgedCheckbox).toBeVisible(); + await versionChangeAcknowledgedCheckbox.check(); + + // now, click the damn button + const btnVersionUpdate = getByRole(page, 'button', 'advanced_version_update_button'); + await expect(btnVersionUpdate).toBeVisible(); + await btnVersionUpdate.click(); + + // Upgrade is a very time-consuming process, so we'll give it a generous timeout + const timeout = 5 * 60 * 1000; + + // LoadingState -> Checking for updates... + await expect(getByText(page, 'general_update_checking_title'), + 'UpdatingDeviceState: checking for updates...', + ).toBeVisible({ timeout }); + + // UpdateAvailableState -> Prompt to update the device + await expect(getByText(page, 'general_update_available_title'), + 'should be visible when general update available title is visible', + ).toBeVisible({ timeout }); + await expect(getByText(page, 'general_update_will_disable_auto_update_description'), + 'UpdateAvailableState: should show warning if it\'s a custom update', + ).toBeVisible(); + + const btnUpdateNow = getByRole(page, 'button', 'general_update_now_button'); + await expect(btnUpdateNow, + 'Update Now button should be visible', + ).toBeVisible(); + await btnUpdateNow.click(); + + // LoadingState -> Updating your device... + await expect(getByText(page, 'general_update_updating_title'), + 'UpdatingDeviceState: title should be updating your device...', + ).toBeVisible(); + const update_type = m.general_update_system_type(); + + // UpdatingDeviceState -> Downloading, Verifying, Installing, Awaiting reboot or rebooting + await expect( + getByText(page, 'general_update_status_downloading', { update_type }). + or(getByText(page, 'general_update_status_verifying', { update_type })). + or(getByText(page, 'general_update_status_installing', { update_type })), + 'UpdatingDeviceState: downloading, verifying, installing', + ).toBeVisible({ timeout }); + await expect( + getByText(page, 'general_update_status_awaiting_reboot') + .or(getByText(page, 'general_update_rebooting')), + 'UpdatingDeviceState: awaiting reboot or rebooting', + ).toBeVisible({ timeout }); + + // Leaving device on + // await expect(pageGetByText('updating_leave_device_on')).toBeVisible({ timeout }); + + // Rebooting or different IP message + await expect( + getByText(page, 'video_overlay_reboot_device_is_rebooting') + .or(getByText(page, 'video_overlay_reboot_different_ip_message')) + , 'VideoOverlay should show either reboot device is rebooting or different ip message' + ).toBeVisible({ timeout }); + + // Get the element indicating WebRTC connection status + // TODO: use the role="status" to filter the element instead of the classNames + const peerConnectionStatusCard = page.locator('.flex.items-center.gap-x-3.border.bg-white', { hasText: m.jetkvm_device() }); + // we're testing against the production build, so react locators aren't available here + await expect( + peerConnectionStatusCard, + 'PeerConnectionStatusCard should be visible' + ).toBeVisible({ timeout }); + + // it should transition to the disconnected state + await expect( + peerConnectionStatusCard.getByText(m.peer_connection_disconnected()), + 'PeerConnectionStatusCard should be transitioned to the DISCONNECTED state', + ).toBeVisible({ timeout }); + + // then, wait for the peer connection status card to transition to the CONNECTED state + await expect( + peerConnectionStatusCard.getByText(m.peer_connection_connected()), + 'PeerConnectionStatusCard should be transitioned to the CONNECTED state after reboot', + ).toBeVisible({ timeout }); +}); diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 96ddf387..7976af1b 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -54,7 +54,8 @@ } }, "include": [ - "src" + "src", + "tests/**/*.ts" ], "references": [ {