diff options
| author | Tobias Järvelöv <tobias.jarvelov@mullvad.net> | 2025-10-06 10:08:36 +0200 |
|---|---|---|
| committer | Tobias Järvelöv <tobias.jarvelov@mullvad.net> | 2025-10-06 10:08:36 +0200 |
| commit | f82099962e5160b4577038b794170fa2f70ed546 (patch) | |
| tree | 5ac6819bca247e14e560eae5d43355f32f40e770 | |
| parent | 430e860b6e267f767c06edba0e7a2e4ef793cc4e (diff) | |
| parent | fa9f91a4f11dc13c64c6a007ceeb6fb08e956e52 (diff) | |
| download | mullvadvpn-f82099962e5160b4577038b794170fa2f70ed546.tar.xz mullvadvpn-f82099962e5160b4577038b794170fa2f70ed546.zip | |
Merge branch 'add-ui-test-for-linux-split-tunneling-des-2429'
8 files changed, 300 insertions, 81 deletions
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/launch-button/LaunchButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/launch-button/LaunchButton.tsx index 58c82d4c42..5f9262cd1f 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/launch-button/LaunchButton.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/launch-button/LaunchButton.tsx @@ -1,12 +1,12 @@ import { messages } from '../../../../../../../../../../../../../../shared/gettext'; import { Button } from '../../../../../../../../../../../../../lib/components'; -import { useLaunchApplication } from '../../../../hooks'; +import { useHandleClick } from './hooks'; export function LaunchButton() { - const launchApplication = useLaunchApplication(); + const handleClick = useHandleClick(); return ( - <Button onClick={launchApplication}> + <Button onClick={handleClick}> <Button.Text> { // TRANSLATORS: Button label for launching an application with split tunneling. diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/launch-button/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/launch-button/hooks/index.ts new file mode 100644 index 0000000000..ea402d648a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/launch-button/hooks/index.ts @@ -0,0 +1 @@ +export * from './useHandleClick'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/launch-button/hooks/useHandleClick.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/launch-button/hooks/useHandleClick.ts new file mode 100644 index 0000000000..cbbd92bdfc --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/split-tunneling/components/linux-settings/components/linux-application-list/components/linux-application-row/components/warning-dialog/components/launch-button/hooks/useHandleClick.ts @@ -0,0 +1,13 @@ +import { useCallback } from 'react'; + +import { useLinuxApplicationRowContext } from '../../../../../LinuxApplicationRowContext'; + +export function useHandleClick() { + const { application, onSelect, setShowWarningDialog } = useLinuxApplicationRowContext(); + const handleClick = useCallback(() => { + setShowWarningDialog(false); + onSelect?.(application); + }, [application, onSelect, setShowWarningDialog]); + + return handleClick; +} diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/mocked-utils.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/mocked-utils.ts index 14ee96e925..bada4ae5ff 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/mocked-utils.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/mocked-utils.ts @@ -94,9 +94,9 @@ export const createMockIpcExpect = ( ) => { const type = 'type' in spec ? spec.type : 'invoke'; - return <T>(): Promise<T> => { + return <T>(response: T): Promise<T> => { return electronApp.evaluate( - ({ ipcMain }, { event, type }) => { + ({ ipcMain }, { event, type, response }) => { return new Promise<T>((resolve) => { if (type === 'send') { ipcMain.once(event, (_event, arg) => resolve(arg)); @@ -105,13 +105,13 @@ export const createMockIpcExpect = ( resolve(arg); return { type: 'success', - value: null, + value: response, }; }); } }); }, - { event, type }, + { event, type, response }, ); }; }; @@ -155,14 +155,14 @@ type IpcMockedTestExtraHandlerKey< type IpcMockedTestFn<I extends AnyIpcCall> = I['direction'] extends 'main-to-renderer' ? Async<NonNullable<ReturnType<I['send']>>> - : (response: Awaited<ReturnType<Parameters<ReturnType<I['receive']>>[0]>>) => Promise<void>; + : (response?: Awaited<ReturnType<Parameters<ReturnType<I['receive']>>[0]>>) => Promise<void>; export type IpcMockedTest<S extends Schema> = { [G in keyof S]: { [K in keyof S[G]]: { [C in IpcMockedTestKey<S[G][K]>]: IpcMockedTestFn<S[G][K]>; } & { - [C in IpcMockedTestExtraHandlerKey<S[G][K], 'expect'>]: () => Promise<void>; + [C in IpcMockedTestExtraHandlerKey<S[G][K], 'expect'>]: IpcMockedTestFn<S[G][K]>; } & { [C in IpcMockedTestExtraHandlerKey<S[G][K], 'ignore'>]: () => Promise<void>; } & { diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/split-tunneling/helpers.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/split-tunneling/helpers.ts new file mode 100644 index 0000000000..fa5143bd1b --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/split-tunneling/helpers.ts @@ -0,0 +1,28 @@ +import { ILinuxSplitTunnelingApplication } from '../../../../src/shared/application-types'; + +export const linuxApplicationsList: ILinuxSplitTunnelingApplication[] = [ + { + absolutepath: '/app', + exec: 'app', + name: 'app', + type: 'Application', + icon: '', + warning: undefined, + }, + { + absolutepath: '/launches-elsewhere', + exec: 'launches-elsewhere', + name: 'launches-elsewhere', + type: 'Application', + icon: '', + warning: 'launches-elsewhere', + }, + { + absolutepath: '/launches-in-existing-process', + exec: 'launches-in-existing-process', + name: 'launches-in-existing-process', + type: 'Application', + icon: '', + warning: 'launches-in-existing-process', + }, +]; diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/split-tunneling/split-tunneling.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/split-tunneling/split-tunneling.spec.ts index 9dba19e1e1..cb0f0d717e 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/split-tunneling/split-tunneling.spec.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/split-tunneling/split-tunneling.spec.ts @@ -4,89 +4,190 @@ import { Page } from 'playwright'; import { RoutePath } from '../../../../src/shared/routes'; import { RoutesObjectModel } from '../../route-object-models'; import { MockedTestUtils, startMockedApp } from '../mocked-utils'; +import { linuxApplicationsList } from './helpers'; let page: Page; let util: MockedTestUtils; let routes: RoutesObjectModel; -test.describe('Split tunneling', () => { +const startup = async () => { + ({ page, util } = await startMockedApp()); + routes = new RoutesObjectModel(page, util); + + await util.expectRoute(RoutePath.main); + await routes.main.gotoSettings(); + await routes.settings.gotoSplitTunnelingSettings(); +}; + +test.describe('Linux Split tunneling unsupported', () => { + if (process.platform !== 'linux') { + test.skip(); + } + + test.afterAll(async () => { + await page.close(); + }); + + test.beforeAll(async () => { + await startup(); + }); + test.beforeAll(async () => { - ({ page, util } = await startMockedApp()); - routes = new RoutesObjectModel(page, util); + await util.ipc.linuxSplitTunneling.isSplitTunnelingSupported.handle(false); + await util.ipc.linuxSplitTunneling.getApplications.handle(linuxApplicationsList); + }); + + test('App should show unsupported dialog when link in header is clicked', async () => { + // Open the unsupported dialog + await routes.splitTunnelingSettings.openUnsupportedDialog(); + const unsupportedText = routes.splitTunnelingSettings.getSplitTunnelingUnsupportedDialogText(); + await expect(unsupportedText).toBeVisible(); + + // Close the unsupported dialog + await routes.splitTunnelingSettings.closeUnsupportedDialog(); + await expect(unsupportedText).not.toBeVisible(); + }); + + test('App list items should be shown even when split tunneling is unsupported', async () => { + // Apps should be shown if split tunneling is unsupported + const linuxApplications = routes.splitTunnelingSettings.getLinuxApplications(); + await expect(linuxApplications).toHaveCount(3); + }); + + test('App list items should show unsupported dialog when clicked', async () => { + // Ensure clicking an application in the list makes the unsupported dialog visible + const linuxApplications = routes.splitTunnelingSettings.getLinuxApplications(); + await linuxApplications.first().click(); + const unsupportedText = routes.splitTunnelingSettings.getSplitTunnelingUnsupportedDialogText(); + await expect(unsupportedText).toBeVisible(); - await util.expectRoute(RoutePath.main); - await routes.main.gotoSettings(); - await routes.settings.gotoSplitTunnelingSettings(); + // Close the unsupported dialog + await routes.splitTunnelingSettings.closeUnsupportedDialog(); + await expect(unsupportedText).not.toBeVisible(); }); +}); + +test.describe('Linux Split tunneling supported', () => { + if (process.platform !== 'linux') { + test.skip(); + } test.afterAll(async () => { await page.close(); }); - test.describe('Linux Split tunneling unsupported', () => { - if (process.platform !== 'linux') { - test.skip(); - } + test.beforeAll(async () => { + await startup(); + }); + + test.beforeAll(async () => { + await util.ipc.linuxSplitTunneling.isSplitTunnelingSupported.handle(true); + await util.ipc.linuxSplitTunneling.getApplications.handle(linuxApplicationsList); + }); + + test('App list items should be shown', async () => { + const linuxApplications = routes.splitTunnelingSettings.getLinuxApplications(); + await expect(linuxApplications).toHaveCount(3); + }); + + test('App list items should be filterered when searching', async () => { + // List should be unfiltered at first + const linuxApplications = routes.splitTunnelingSettings.getLinuxApplications(); + await expect(linuxApplications).toHaveCount(3); + + // List should only show 2 matching items + await routes.splitTunnelingSettings.fillSearchInput('launches'); + await expect(linuxApplications).toHaveCount(2); + let applicationNames = await linuxApplications.allInnerTexts(); + expect(applicationNames).toEqual(['launches-elsewhere', 'launches-in-existing-process']); + + // List should only show 1 matching item + await routes.splitTunnelingSettings.fillSearchInput('app'); + await expect(linuxApplications).toHaveCount(1); + applicationNames = await linuxApplications.allInnerTexts(); + expect(applicationNames).toEqual(['app']); + + // Clearing the search value should show all list items + await routes.splitTunnelingSettings.clearSearchInput(); + await expect(linuxApplications).toHaveCount(3); + applicationNames = await linuxApplications.allInnerTexts(); + expect(applicationNames).toEqual(['app', 'launches-elsewhere', 'launches-in-existing-process']); + }); + + test('App list items should be launched when clicked', async () => { + // Launch the "app" application from the list + await Promise.all([ + util.ipc.linuxSplitTunneling.launchApplication.expect({ success: true }), + routes.splitTunnelingSettings.openLinuxApplication('app'), + ]); + }); + + test('App list items with "launches-in-existing-process" warnings should show warning dialog when clicked', async () => { + // Try to open the application in the list to display the warning dialog + await routes.splitTunnelingSettings.openLinuxApplication('launches-in-existing-process'); + + // Ensure warning dialog is visible + const warningText = + routes.splitTunnelingSettings.getLinuxApplicationWarningLaunchesInExistingProcessDialogText( + 'launches-in-existing-process', + ); + await expect(warningText).toBeVisible(); - test.beforeAll(async () => { - await util.ipc.linuxSplitTunneling.isSplitTunnelingSupported.handle(false); - await util.ipc.linuxSplitTunneling.getApplications.handle([ - { - absolutepath: '/app', - exec: 'app', - name: 'app', - type: 'app', - icon: '', - warning: undefined, - }, - { - absolutepath: '/launches-elsewhere', - exec: 'launches-elsewhere', - name: 'launches-elsewhere', - type: 'launches-elsewhere', - icon: '', - warning: 'launches-elsewhere', - }, - { - absolutepath: '/launches-in-existing-process', - exec: 'launches-in-existing-process', - name: 'launches-in-existing-process', - type: 'launches-in-existing-process', - icon: '', - warning: 'launches-in-existing-process', - }, - ]); - }); + // Close the warning dialog + await routes.splitTunnelingSettings.closeLinuxApplicationWarningLaunchesInExistingProcessDialog(); + await expect(warningText).not.toBeVisible(); - test('App should show unsupported dialog when link in header is clicked', async () => { - // Open the unsupported dialog - await routes.splitTunnelingSettings.openUnsupportedDialog(); - const unsupportedText = - routes.splitTunnelingSettings.getSplitTunnelingUnsupportedDialogText(); - await expect(unsupportedText).toBeVisible(); + // Try to open the application again to display the warning dialog again + await routes.splitTunnelingSettings.openLinuxApplication('launches-in-existing-process'); + await expect(warningText).toBeVisible(); - // Close the unsupported dialog - await routes.splitTunnelingSettings.closeUnsupportedDialog(); - await expect(unsupportedText).not.toBeVisible(); - }); + // Launch the application from the warning dialog + await Promise.all([ + util.ipc.linuxSplitTunneling.launchApplication.expect({ success: true }), + routes.splitTunnelingSettings.openLinuxApplicationFromWarningLaunchesInExistingProcessDialogText(), + ]); - test('App list items should be shown even when split tunneling is unsupported', async () => { - // Apps should be shown if split tunneling is unsupported - const linuxApplications = routes.splitTunnelingSettings.getLinuxApplications(); - await expect(linuxApplications).toHaveCount(3); - }); + // Ensure the warning dialog is not visible after application has launched + await expect(warningText).not.toBeVisible(); + }); + + test('App list items with "launches-elsewhere" warnings should show warning dialog when clicked', async () => { + // Ensure clicking the application in the list makes the warning dialog visible + await routes.splitTunnelingSettings.openLinuxApplication('launches-elsewhere'); + + const warningText = + routes.splitTunnelingSettings.getLinuxApplicationWarningLaunchesElsewhereDialogText( + 'launches-elsewhere', + ); + await expect(warningText).toBeVisible(); + + // Close the warning dialog + await routes.splitTunnelingSettings.closeLinuxApplicationWarningLaunchesElsewhereDialog(); + await expect(warningText).not.toBeVisible(); + }); - test('App list items should show unsupported dialog when clicked', async () => { - // Ensure clicking an application in the list makes the unsupported dialog visible - const linuxApplications = routes.splitTunnelingSettings.getLinuxApplications(); - await linuxApplications.first().click(); - const unsupportedText = - routes.splitTunnelingSettings.getSplitTunnelingUnsupportedDialogText(); - await expect(unsupportedText).toBeVisible(); + test('App should launch file picker when button Find another app button is clicked', async () => { + // Ensure clicking the "Find another app" button opens the file picker + await Promise.all([ + util.ipc.app.showOpenDialog.expect({ + canceled: false, + bookmarks: [], + filePaths: [], + }), + routes.splitTunnelingSettings.openFindAnotherApp(), + ]); - // Close the unsupported dialog - await routes.splitTunnelingSettings.closeUnsupportedDialog(); - await expect(unsupportedText).not.toBeVisible(); - }); + // Ensure selecting an application with the file picker will launch the application + await Promise.all([ + util.ipc.app.showOpenDialog.expect({ + canceled: false, + bookmarks: [], + filePaths: ['/app'], + }), + routes.splitTunnelingSettings.openFindAnotherApp(), + util.ipc.linuxSplitTunneling.launchApplication.expect({ + success: true, + }), + ]); }); }); diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/split-tunneling-settings/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/split-tunneling-settings/selectors.ts index f7208eab3a..046d29f435 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/split-tunneling-settings/selectors.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/split-tunneling-settings/selectors.ts @@ -1,17 +1,45 @@ import { type Page } from 'playwright'; export const createSelectors = (page: Page) => ({ - splitTunnelingUnsupportedDialogOpenLink: () => + applicationWarningLaunchesElsewhereDialogText: (applicationName: string) => + page.getByText(`${applicationName} is problematic and can’t be excluded from the VPN tunnel.`), + applicationWarningLaunchesInExistingProcessDialogText: (applicationName: string) => + page.getByText( + `If it’s already running, close ${applicationName} before launching it from here. Otherwise it might not be excluded from the VPN tunnel.`, + ), + findAnotherAppButton: () => page.getByRole('button', { - name: 'Click here to learn more', + name: 'Find another app', + }), + linuxApplication: (applicationName: string) => + page.getByRole('button', { + name: applicationName, + exact: true, + }), + linuxApplications: () => page.getByTestId('linux-applications').getByRole('button'), + linuxApplicationWarningDialogBackButton: () => + page.getByRole('dialog').getByRole('button', { + name: 'Back', }), + linuxApplicationWarningDialogCancelButton: () => + page.getByRole('dialog').getByRole('button', { + name: 'Cancel', + }), + linuxApplicationWarningDialogLaunchButton: () => + page.getByRole('dialog').getByRole('button', { + name: 'Launch', + }), + searchInput: () => page.getByPlaceholder('Search for...'), splitTunnelingUnsupportedDialogCloseButton: () => page.getByRole('button', { name: 'Got it!', }), + splitTunnelingUnsupportedDialogOpenLink: () => + page.getByRole('button', { + name: 'Click here to learn more', + }), splitTunnelingUnsupportedDialogText: () => page.getByText( 'To use Split tunneling, please change to a Linux kernel version that supports cgroup v1.', ), - linuxApplications: () => page.getByTestId('linux-applications').locator('button'), }); diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/split-tunneling-settings/split-tunneling-settings-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/split-tunneling-settings/split-tunneling-settings-route-object-model.ts index d0de2cc413..5623cf160e 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/split-tunneling-settings/split-tunneling-settings-route-object-model.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/split-tunneling-settings/split-tunneling-settings-route-object-model.ts @@ -15,22 +15,70 @@ export class SplitTunnelingSettingsRouteObjectModel { this.selectors = createSelectors(page); } - async waitForRoute() { - await this.utils.expectRoute(RoutePath.splitTunneling); + getLinuxApplication(applicationName: string) { + return this.selectors.linuxApplication(applicationName); } getLinuxApplications() { return this.selectors.linuxApplications(); } - getSplitTunnelingUnsupportedDialogText() { - return this.selectors.splitTunnelingUnsupportedDialogText(); + openFindAnotherApp() { + return this.selectors.findAnotherAppButton().click(); + } + + openLinuxApplication(applicationName: string) { + return this.getLinuxApplication(applicationName).click(); + } + + async waitForRoute() { + await this.utils.expectRoute(RoutePath.splitTunneling); + } + + // Search input + + async clearSearchInput() { + await this.selectors.searchInput().clear(); + } + + async fillSearchInput(value: string) { + await this.selectors.searchInput().fill(value); + } + + // Launches elsewhere + + closeLinuxApplicationWarningLaunchesElsewhereDialog() { + return this.selectors.linuxApplicationWarningDialogBackButton().click(); + } + + getLinuxApplicationWarningLaunchesElsewhereDialogText(applicationName: string) { + return this.selectors.applicationWarningLaunchesElsewhereDialogText(applicationName); + } + + // Launches in existing process + + closeLinuxApplicationWarningLaunchesInExistingProcessDialog() { + return this.selectors.linuxApplicationWarningDialogCancelButton().click(); + } + + getLinuxApplicationWarningLaunchesInExistingProcessDialogText(applicationName: string) { + return this.selectors.applicationWarningLaunchesInExistingProcessDialogText(applicationName); + } + + openLinuxApplicationFromWarningLaunchesInExistingProcessDialogText() { + return this.selectors.linuxApplicationWarningDialogLaunchButton().click(); } + // Unsupported dialog + closeUnsupportedDialog() { return this.selectors.splitTunnelingUnsupportedDialogCloseButton().click(); } + getSplitTunnelingUnsupportedDialogText() { + return this.selectors.splitTunnelingUnsupportedDialogText(); + } + openUnsupportedDialog() { return this.selectors.splitTunnelingUnsupportedDialogOpenLink().click(); } |
