diff options
| author | Oskar <oskar@mullvad.net> | 2025-09-05 11:47:10 +0200 |
|---|---|---|
| committer | Oskar <oskar@mullvad.net> | 2025-09-05 11:47:10 +0200 |
| commit | 5fa06507df5386a970a44ed14f71e7a289657b99 (patch) | |
| tree | 5bcffd2a07f6d3fac68ec05ad5d08dbbbf45b81d | |
| parent | 22eb93188789974397554453b52a2f265989535d (diff) | |
| parent | 6ba919b6d0f594998259180489e30f79513420ef (diff) | |
| download | mullvadvpn-5fa06507df5386a970a44ed14f71e7a289657b99.tar.xz mullvadvpn-5fa06507df5386a970a44ed14f71e7a289657b99.zip | |
Merge branch 'improve-mocked-tests'
17 files changed, 265 insertions, 331 deletions
diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index b410fa6620..25fb01f3dd 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -68,10 +68,10 @@ jobs: working-directory: desktop # The sandbox is disabled as a workaround for lacking userns permisisons which is required # since Ubuntu 24.04. - run: NO_SANDBOX=1 npm run e2e:no-build -w mullvad-vpn + run: NO_SANDBOX=1 npm run e2e:no-build -w mullvad-vpn -- --reporter=line - name: Run Playwright tests on Windows/macOS if: runner.os != 'Linux' working-directory: desktop shell: bash - run: npm run e2e:no-build --w mullvad-vpn + run: npm run e2e:no-build --w mullvad-vpn -- --reporter=line diff --git a/desktop/packages/mullvad-vpn/src/shared/ipc-helpers.ts b/desktop/packages/mullvad-vpn/src/shared/ipc-helpers.ts index db219f4535..559b48c22b 100644 --- a/desktop/packages/mullvad-vpn/src/shared/ipc-helpers.ts +++ b/desktop/packages/mullvad-vpn/src/shared/ipc-helpers.ts @@ -16,20 +16,22 @@ interface MainToRenderer<T> { interface RendererToMain<T, R> { direction: 'renderer-to-main'; + type: 'send' | 'invoke'; send: (event: string, ipcRenderer: EIpcRenderer) => Sender<T, R>; receive: (event: string, ipcMain: EIpcMain) => Handler<T, R>; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyIpcCall = MainToRenderer<any> | RendererToMain<any, any>; +export type AnyIpcCall = MainToRenderer<any> | RendererToMain<any, any>; export type Schema = Record<string, Record<string, AnyIpcCall>>; // Renames all IPC calls, e.g. `callName` to either `notifyCallName` or `handleCallName` depending // on direction. -type IpcMainKey<N extends string, I extends AnyIpcCall> = I['direction'] extends 'main-to-renderer' - ? `notify${Capitalize<N>}` - : `handle${Capitalize<N>}`; +export type IpcMainKey< + N extends string, + I extends AnyIpcCall, +> = I['direction'] extends 'main-to-renderer' ? `notify${Capitalize<N>}` : `handle${Capitalize<N>}`; // Selects either the send or receive function depending on direction. type IpcMainFn<I extends AnyIpcCall> = I['direction'] extends 'main-to-renderer' @@ -61,11 +63,6 @@ export type IpcRenderer<S extends Schema> = { }; }; -// Transforms the provided schema into containing only the event keys. -export type IpcEvents<S> = { - [G in keyof S]: { [C in keyof S[G]]: string }; -}; - // Preforms the transformation of the main event channel in accordance with the above types. export function createIpcMain<S extends Schema>( schema: S, @@ -104,15 +101,10 @@ export function createIpcRenderer<S extends Schema>( }); } -export function createIpcEvents<S extends Schema>(schema: S): IpcEvents<S> { - return createIpc(schema, (event, key) => [key, event]); -} - -export function createIpc< - S extends Schema, - T, - R extends IpcMain<S> | IpcRenderer<S> | IpcEvents<S>, ->(ipc: S, fn: (event: string, key: string, spec: AnyIpcCall) => [newKey: string, newValue: T]): R { +export function createIpc<S extends Schema, T, R>( + ipc: S, + fn: (event: string, key: string, spec: AnyIpcCall) => [newKey: string, newValue: T], +): R { return Object.fromEntries( Object.entries(ipc).map(([groupKey, group]) => { const newGroup = Object.fromEntries( @@ -127,6 +119,7 @@ export function createIpc< export function send<T>(): RendererToMain<T, void> { return { direction: 'renderer-to-main', + type: 'send', send: (event, ipcRenderer) => (newValue: T) => ipcRenderer.send(event, newValue), receive: (event, ipcMain) => (handlerFn: (value: T) => void) => { ipcMain.on(event, (_event, newValue: T) => { @@ -140,6 +133,7 @@ export function send<T>(): RendererToMain<T, void> { export function invokeSync<T, R>(): RendererToMain<T, R> { return { direction: 'renderer-to-main', + type: 'send', send: (event, ipcRenderer) => (newValue: T) => ipcRenderer.sendSync(event, newValue), receive: (event, ipcMain) => (handlerFn: (value: T) => R) => { ipcMain.on(event, (ipcEvent, newValue: T) => { @@ -153,6 +147,7 @@ export function invokeSync<T, R>(): RendererToMain<T, R> { export function invoke<T, R>(): RendererToMain<T, Promise<R>> { return { direction: 'renderer-to-main', + type: 'invoke', send: invokeImpl, receive: handle, }; diff --git a/desktop/packages/mullvad-vpn/src/shared/utility-types.ts b/desktop/packages/mullvad-vpn/src/shared/utility-types.ts new file mode 100644 index 0000000000..1d6c41992f --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/shared/utility-types.ts @@ -0,0 +1,3 @@ +export type Async<F extends (...args: unknown[]) => unknown> = ( + ...args: Parameters<F> +) => Promise<ReturnType<F>>; diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/app-upgrade/app-upgrade.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/app-upgrade/app-upgrade.spec.ts index 6015f1b4fe..d06405428e 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/app-upgrade/app-upgrade.spec.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/app-upgrade/app-upgrade.spec.ts @@ -3,12 +3,17 @@ import { Page } from 'playwright'; import { RoutePath } from '../../../../src/shared/routes'; import { MockedTestUtils, startMockedApp } from '../mocked-utils'; -import { createHelpers, createIpc, createSelectors, mockData, resolveIpcHandle } from './helpers'; +import { + createAppUpgradeEventIpcHelper, + createHelpers, + createSelectors, + mockData, +} from './helpers'; let page: Page; let util: MockedTestUtils; let helpers: ReturnType<typeof createHelpers>; -let ipc: ReturnType<typeof createIpc>; +let upgradeEventIpc: ReturnType<typeof createAppUpgradeEventIpcHelper>; let selectors: ReturnType<typeof createSelectors>; test.describe('App upgrade', () => { @@ -20,12 +25,12 @@ test.describe('App upgrade', () => { ({ page, util } = await startMockedApp()); helpers = createHelpers(page, util); - ipc = createIpc(util); + upgradeEventIpc = createAppUpgradeEventIpcHelper(util); selectors = createSelectors(page); await util.waitForRoute(RoutePath.main); - await ipc.send.upgradeVersion({ + await util.ipc.upgradeVersion[''].notify({ supported: true, suggestedIsBeta: false, suggestedUpgrade: { @@ -34,6 +39,8 @@ test.describe('App upgrade', () => { }, }); + await util.ipc.app.getUpgradeCacheDir.ignore(); + await page.click('button[aria-label="Settings"]'); await util.waitForRoute(RoutePath.settings); await page.getByRole('button', { name: 'App info' }).click(); @@ -96,7 +103,7 @@ test.describe('App upgrade', () => { test('Should show download progress after receiving event', async () => { const mockedProgress = 90; - await ipc.send.appUpgradeEventDownloadProgress({ + await upgradeEventIpc.send.appUpgradeEventDownloadProgress({ progress: mockedProgress, server: 'cdn.mullvad.net', timeLeft: 120, @@ -109,7 +116,7 @@ test.describe('App upgrade', () => { }); test('Should verify installer when download is complete', async () => { - await ipc.send.appUpgradeEventVerifyingInstaller(); + await upgradeEventIpc.send.appUpgradeEventVerifyingInstaller(); await expect(page.getByText('Verifying installer')).toBeVisible(); await expect(page.getByText('Download complete')).toBeVisible(); @@ -118,7 +125,7 @@ test.describe('App upgrade', () => { }); test('Should show that it has verified the installer when verification is complete', async () => { - await ipc.send.appUpgradeEventVerifiedInstaller(); + await upgradeEventIpc.send.appUpgradeEventVerifiedInstaller(); await expect(page.getByText('Verification successful!')).toBeVisible(); await expect(page.getByText('Download complete')).toBeVisible(); @@ -131,7 +138,7 @@ test.describe('App upgrade', () => { test.afterAll(() => restart()); test('Should handle failing to download upgrade', async () => { - await ipc.send.appUpgradeError('DOWNLOAD_FAILED'); + await util.ipc.app.upgradeError.notify('DOWNLOAD_FAILED'); await expect( page.getByText( @@ -149,7 +156,7 @@ test.describe('App upgrade', () => { test('Should handle retrying download of upgrade', async () => { const retryButton = selectors.retryButton(); - await resolveIpcHandle(ipc.handle.appUpgrade(), retryButton.click()); + await Promise.all([util.ipc.app.upgrade.expect(), retryButton.click()]); await expect(page.getByText('Downloading...')).toBeVisible(); await expect(page.getByText('Starting download...')).toBeVisible(); @@ -164,9 +171,9 @@ test.describe('App upgrade', () => { // This test should fail due to the window not being focused, // which is a pre-requisite for launching the installer automatically. test('Should handle installer failing to start automatically', async () => { - await ipc.send.windowFocus(false); + await util.ipc.window.focus.notify(false); - await ipc.send.upgradeVersion({ + await util.ipc.upgradeVersion[''].notify({ supported: true, suggestedIsBeta: false, suggestedUpgrade: { @@ -176,7 +183,7 @@ test.describe('App upgrade', () => { }, }); - await ipc.send.appUpgradeEventVerifiedInstaller(); + await upgradeEventIpc.send.appUpgradeEventVerifiedInstaller(); const installUpdateButton = selectors.installButton(); @@ -188,10 +195,10 @@ test.describe('App upgrade', () => { test('Should handle installer failing to start manually', async () => { const installUpdateButton = selectors.installButton(); - await resolveIpcHandle(ipc.handle.appUpgradeInstallerStart(), installUpdateButton.click()); + await Promise.all([util.ipc.app.upgradeInstallerStart.expect(), installUpdateButton.click()]); - await ipc.send.appUpgradeEventExitedInstaller(); - await ipc.send.appUpgradeError('START_INSTALLER_FAILED'); + await upgradeEventIpc.send.appUpgradeEventExitedInstaller(); + await util.ipc.app.upgradeError.notify('START_INSTALLER_FAILED'); await expect(installUpdateButton).not.toBeVisible(); @@ -212,13 +219,13 @@ test.describe('App upgrade', () => { // Call the retry button 2 additional times, to increase the total // errorCount to 3 in order for the ManualDownloadLink to be shown. - await resolveIpcHandle(ipc.handle.appUpgradeInstallerStart(), retryButton.click()); - await ipc.send.appUpgradeEventExitedInstaller(); - await ipc.send.appUpgradeError('START_INSTALLER_FAILED'); + await Promise.all([util.ipc.app.upgradeInstallerStart.expect(), retryButton.click()]); + await upgradeEventIpc.send.appUpgradeEventExitedInstaller(); + await util.ipc.app.upgradeError.notify('START_INSTALLER_FAILED'); - await resolveIpcHandle(ipc.handle.appUpgradeInstallerStart(), retryButton.click()); - await ipc.send.appUpgradeEventExitedInstaller(); - await ipc.send.appUpgradeError('START_INSTALLER_FAILED'); + await Promise.all([util.ipc.app.upgradeInstallerStart.expect(), retryButton.click()]); + await upgradeEventIpc.send.appUpgradeEventExitedInstaller(); + await util.ipc.app.upgradeError.notify('START_INSTALLER_FAILED'); const manualDownloadLink = selectors.manualDownloadLink(); await expect(manualDownloadLink).toBeVisible(); @@ -237,11 +244,11 @@ test.describe('App upgrade', () => { test('Should pause upgrade when clicking the Pause button', async () => { const pauseButton = selectors.pauseButton(); - await resolveIpcHandle(ipc.handle.appUpgradeAbort(), pauseButton.click()); + await Promise.all([util.ipc.app.upgradeAbort.expect(), pauseButton.click()]); // After the app upgrade abort RPC is sent we expect to receive an aborted // event. - await ipc.send.appUpgradeEventAborted(); + await upgradeEventIpc.send.appUpgradeEventAborted(); await expect(pauseButton).toBeHidden(); @@ -253,7 +260,7 @@ test.describe('App upgrade', () => { test('Should start upgrade again when clicking Resume button', async () => { const resumeButton = selectors.resumeButton(); - await resolveIpcHandle(ipc.handle.appUpgrade(), resumeButton.click()); + await Promise.all([util.ipc.app.upgrade.expect(), resumeButton.click()]); await expect(resumeButton).toBeHidden(); }); diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/app-upgrade/helpers.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/app-upgrade/helpers.ts index 9c2570f04b..ffb11effb7 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/app-upgrade/helpers.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/app-upgrade/helpers.ts @@ -1,32 +1,15 @@ import { expect } from '@playwright/test'; import { Page } from 'playwright'; -import { AppUpgradeError, AppUpgradeEvent } from '../../../../src/shared/app-upgrade'; -import { - DaemonAppUpgradeEventStatusDownloadProgress, - IAppVersionInfo, -} from '../../../../src/shared/daemon-rpc-types'; +import { AppUpgradeEvent } from '../../../../src/shared/app-upgrade'; +import { DaemonAppUpgradeEventStatusDownloadProgress } from '../../../../src/shared/daemon-rpc-types'; import { MockedTestUtils } from '../mocked-utils'; -export const createIpc = (util: MockedTestUtils) => { - const createMockHandle = <T>(channel: string, response?: T) => - util.mockIpcHandle<T | undefined>({ channel, response }); - - const createMockResponse = <T>(channel: string, response: T) => - util.sendMockIpcResponse<T>({ - channel, - response, - }); - +export const createAppUpgradeEventIpcHelper = (util: MockedTestUtils) => { const createMockResponseAppUpgradeEvent = (event: AppUpgradeEvent) => - createMockResponse<AppUpgradeEvent>('app-upgradeEvent', event); + util.ipc.app.upgradeEvent.notify(event); return { - handle: { - appUpgrade: () => createMockHandle('appUpgrade'), - appUpgradeAbort: () => createMockHandle('appUpgradeAbort'), - appUpgradeInstallerStart: () => createMockHandle('appUpgradeInstallerStart'), - }, send: { appUpgradeEventAborted: () => createMockResponseAppUpgradeEvent({ @@ -59,11 +42,6 @@ export const createIpc = (util: MockedTestUtils) => { createMockResponseAppUpgradeEvent({ type: 'APP_UPGRADE_STATUS_EXITED_INSTALLER', }), - appUpgradeError: (error: AppUpgradeError) => - createMockResponse<AppUpgradeError>('app-upgradeError', error), - upgradeVersion: (data: IAppVersionInfo) => - createMockResponse<IAppVersionInfo>('upgradeVersion-', data), - windowFocus: (value: boolean) => createMockResponse<boolean>('window-focus', value), }, }; }; @@ -110,21 +88,14 @@ export const mockData = { version: '2100.1', }; -export const resolveIpcHandle = async (test: Promise<void>, trigger: Promise<void>) => { - // The promise is resolved when its handle has been called. - // The handle should be called when the trigger is called. - const promise = await Promise.all([test, trigger]); - expect(promise).toBeTruthy(); -}; - export const createHelpers = (page: Page, util: MockedTestUtils) => { const selectors = createSelectors(page); - const ipc = createIpc(util); + const ipc = createAppUpgradeEventIpcHelper(util); const startAppUpgrade = async () => { const downloadAndLaunchInstallerButton = selectors.downloadAndLaunchInstallerButton(); - await resolveIpcHandle(ipc.handle.appUpgrade(), downloadAndLaunchInstallerButton.click()); + await Promise.all([util.ipc.app.upgrade.expect(), downloadAndLaunchInstallerButton.click()]); await ipc.send.appUpgradeEventDownloadStarted(); }; diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/expired-account-error-view.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/expired-account-error-view.spec.ts index fc1c6d99e7..71c3443704 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/expired-account-error-view.spec.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/expired-account-error-view.spec.ts @@ -2,7 +2,6 @@ import { expect, test } from '@playwright/test'; import { Page } from 'playwright'; import { colorTokens } from '../../../src/renderer/lib/foundations'; -import { IAccountData } from '../../../src/shared/daemon-rpc-types'; import { RoutePath } from '../../../src/shared/routes'; import { getBackgroundColor } from '../utils'; import { MockedTestUtils, startMockedApp } from './mocked-utils'; @@ -20,9 +19,8 @@ test.afterEach(async () => { }); test('App should show Expired Account Error View', async () => { - await util.sendMockIpcResponse<IAccountData>({ - channel: 'account-', - response: { expiry: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() }, + await util.ipc.account[''].notify({ + expiry: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(), }); await expect(page.locator('text=Out of time')).toBeVisible(); @@ -39,9 +37,6 @@ test('App should show out of time view after running out of time', async () => { const expiryDate = new Date(); expiryDate.setSeconds(expiryDate.getSeconds() + 2); - await util.sendMockIpcResponse<IAccountData>({ - channel: 'account-', - response: { expiry: expiryDate.toISOString() }, - }); + await util.ipc.account[''].notify({ expiry: expiryDate.toISOString() }); await util.waitForRoute(RoutePath.expired); }); diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators.spec.ts index 26509664b3..9145068c58 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators.spec.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators.spec.ts @@ -1,12 +1,7 @@ import { expect, test } from '@playwright/test'; import { Page } from 'playwright'; -import { - FeatureIndicator, - ILocation, - ITunnelEndpoint, - TunnelState, -} from '../../../src/shared/daemon-rpc-types'; +import { FeatureIndicator, ILocation, ITunnelEndpoint } from '../../../src/shared/daemon-rpc-types'; import { RoutePath } from '../../../src/shared/routes'; import { expectConnected } from '../shared/tunnel-state'; import { MockedTestUtils, startMockedApp } from './mocked-utils'; @@ -42,17 +37,10 @@ test.afterAll(async () => { }); test('App should show no feature indicators', async () => { - await util.mockIpcHandle<ILocation>({ - channel: 'location-get', - response: mockDisconnectedLocation, - }); - await util.sendMockIpcResponse<TunnelState>({ - channel: 'tunnel-', - response: { - state: 'connected', - details: { endpoint, location: mockConnectedLocation }, - featureIndicators: undefined, - }, + await util.ipc.tunnel[''].notify({ + state: 'connected', + details: { endpoint, location: mockConnectedLocation }, + featureIndicators: undefined, }); await expectConnected(page); @@ -69,28 +57,21 @@ test('App should show no feature indicators', async () => { }); test('App should show feature indicators', async () => { - await util.mockIpcHandle<ILocation>({ - channel: 'location-get', - response: mockDisconnectedLocation, - }); - await util.sendMockIpcResponse<TunnelState>({ - channel: 'tunnel-', - response: { - state: 'connected', - details: { endpoint, location: mockConnectedLocation }, - featureIndicators: [ - FeatureIndicator.daita, - FeatureIndicator.udp2tcp, - FeatureIndicator.customMssFix, - FeatureIndicator.customMtu, - FeatureIndicator.lanSharing, - FeatureIndicator.serverIpOverride, - FeatureIndicator.customDns, - FeatureIndicator.lockdownMode, - FeatureIndicator.quantumResistance, - FeatureIndicator.multihop, - ], - }, + await util.ipc.tunnel[''].notify({ + state: 'connected', + details: { endpoint, location: mockConnectedLocation }, + featureIndicators: [ + FeatureIndicator.daita, + FeatureIndicator.udp2tcp, + FeatureIndicator.customMssFix, + FeatureIndicator.customMtu, + FeatureIndicator.lanSharing, + FeatureIndicator.serverIpOverride, + FeatureIndicator.customDns, + FeatureIndicator.lockdownMode, + FeatureIndicator.quantumResistance, + FeatureIndicator.multihop, + ], }); // Make sure panel is collapsed before checking indicator visibility. diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/launch/launch.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/launch.spec.ts index 30e793d6b8..b9614a29e8 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/launch/launch.spec.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/launch.spec.ts @@ -1,24 +1,21 @@ import { expect, test } from '@playwright/test'; import { Page } from 'playwright'; -import { RoutePath } from '../../../../src/shared/routes'; -import { RoutesObjectModel } from '../../route-object-models'; -import { MockedTestUtils, startMockedApp } from '../mocked-utils'; -import { createIpc } from './ipc'; +import { RoutePath } from '../../../src/shared/routes'; +import { RoutesObjectModel } from '../route-object-models'; +import { MockedTestUtils, startMockedApp } from './mocked-utils'; let page: Page; let util: MockedTestUtils; let routes: RoutesObjectModel; -let ipc: ReturnType<typeof createIpc>; test.describe('Launch', () => { test.beforeAll(async () => { ({ page, util } = await startMockedApp()); - ipc = createIpc(util); routes = new RoutesObjectModel(page, util); await util.waitForRoute(RoutePath.main); - await ipc.send.daemonDisconnected(); + await util.ipc.daemon.disconnected.notify(); await routes.launch.waitForRoute(); }); @@ -54,7 +51,7 @@ test.describe('Launch', () => { await expect(defaultFooterText).toBeVisible(); }); test('Should display permission footer when daemon is not allowed', async () => { - await ipc.send.daemonAllowed(false); + await util.ipc.daemon.daemonAllowed.notify(false); const gotoSystemSettingsButton = routes.launch.selectors.gotoSystemSettingsButton(); await expect(gotoSystemSettingsButton).toBeVisible(); }); diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/launch/ipc.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/launch/ipc.ts deleted file mode 100644 index 6e790c444f..0000000000 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/launch/ipc.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { MockedTestUtils } from '../mocked-utils'; - -export const createIpc = (util: MockedTestUtils) => { - const createMockResponse = <T>(channel: string, response: T) => - util.sendMockIpcResponse<T>({ - channel, - response, - }); - - return { - handle: {}, - send: { - daemonDisconnected: () => createMockResponse('daemon-disconnected', {}), - daemonAllowed: (allowed: boolean) => createMockResponse('daemon-daemonAllowed', allowed), - }, - }; -}; diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts index 46f2dcd6a0..fb868a639e 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts @@ -1,7 +1,6 @@ import { expect, test } from '@playwright/test'; import { Page } from 'playwright'; -import { DeviceEvent } from '../../../src/shared/daemon-rpc-types'; import { RoutesObjectModel } from '../route-object-models'; import { MockedTestUtils, startMockedApp } from './mocked-utils'; @@ -18,9 +17,9 @@ test.describe('Clear account history warnings', () => { }; const logout = async () => { - await util.sendMockIpcResponse<DeviceEvent>({ - channel: util.ipcEvents.account.device, - response: { type: 'logged out', deviceState: { type: 'logged out' } }, + await util.ipc.account.device.notify({ + type: 'logged out', + deviceState: { type: 'logged out' }, }); await routes.login.waitForRoute(); @@ -40,20 +39,14 @@ test.describe('Clear account history warnings', () => { }); const setAccountHistory = async () => { - await util.sendMockIpcResponse({ - channel: util.ipcEvents.accountHistory[''], - response: '1234123412341234', - }); + await util.ipc.accountHistory[''].notify('1234123412341234'); }; test('Should not warn about creating an account', async () => { const accountHistoryItemButton = routes.login.getAccountHistoryItemButton(); await expect(accountHistoryItemButton).not.toBeVisible(); - await Promise.all([ - util.expectIpcCall(util.ipcEvents.account.create), - routes.login.createNewAccount(), - ]); + await Promise.all([util.ipc.account.create.expect(), routes.login.createNewAccount()]); }); test('Should warn about creating an account', async () => { @@ -68,10 +61,7 @@ test.describe('Clear account history warnings', () => { await routes.login.createNewAccount(); - await Promise.all([ - util.expectIpcCall(util.ipcEvents.account.create), - routes.login.confirmCreateNewAccount(), - ]); + await Promise.all([util.ipc.account.create.expect(), routes.login.confirmCreateNewAccount()]); }); test('Should warn about clearing account history', async () => { @@ -90,14 +80,11 @@ test.describe('Clear account history warnings', () => { await routes.login.clearAccountHistory(); await Promise.all([ - util.expectIpcCall(util.ipcEvents.accountHistory.clear), + util.ipc.accountHistory.clear.expect(), routes.login.confirmClearAccountHistory(), ]); - await util.sendMockIpcResponse({ - channel: util.ipcEvents.accountHistory[''], - response: undefined, - }); + await util.ipc.accountHistory[''].notify(undefined); await expect(accountHistoryItemButton).not.toBeVisible(); }); }); 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 0290536418..e6db357f99 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/mocked-utils.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/mocked-utils.ts @@ -1,110 +1,187 @@ import { ElectronApplication } from 'playwright'; -import { createIpcEvents, IpcEvents } from '../../../src/shared/ipc-helpers'; +import { AnyIpcCall, createIpc, Schema } from '../../../src/shared/ipc-helpers'; import { IpcSchema, ipcSchema } from '../../../src/shared/ipc-schema'; +import { Async } from '../../../src/shared/utility-types'; import { startApp, TestUtils } from '../utils'; // This option can be removed in the future when/if we're able to tun the tests with the sandbox // enabled in GitHub actions (frontend.yml). const noSandbox = process.env.NO_SANDBOX === '1'; +const showWindow = process.env.SHOW_WINDOW === '1'; interface StartMockedAppResponse extends Awaited<ReturnType<typeof startApp>> { util: MockedTestUtils; } export interface MockedTestUtils extends TestUtils { - mockIpcHandle: MockIpcHandle; - sendMockIpcResponse: SendMockIpcResponse; - expectIpcCall: ExpectIpcCall; - ipcEvents: IpcEvents<IpcSchema>; + ipc: IpcMockedTest<IpcSchema>; } export const startMockedApp = async (): Promise<StartMockedAppResponse> => { const args = ['.']; + if (noSandbox) { console.log('Running tests without chromium sandbox'); args.unshift('--no-sandbox'); } + + if (showWindow) { + args.unshift('--show-window'); + } + // NOTE: Keep in sync with index.ts args.push('--gtk-version=3'); const startAppResult = await startApp({ args }); - const mockIpcHandle = generateMockIpcHandle(startAppResult.app); - const sendMockIpcResponse = generateSendMockIpcResponse(startAppResult.app); - const expectIpcCall = generateExpectIpcCall(startAppResult.app); return { ...startAppResult, util: { ...startAppResult.util, - mockIpcHandle, - sendMockIpcResponse, - expectIpcCall, - - ipcEvents: createIpcEvents(ipcSchema), + ipc: createTestIpc(startAppResult.app), }, }; }; -type MockIpcHandleProps<T> = { - channel: string; - response: T; +export const createMockIpcNotify = (electronApp: ElectronApplication, event: string) => { + return async <T>(arg: T) => { + await electronApp.evaluate( + ({ webContents }, { event, arg }) => { + webContents + .getAllWebContents() + // Select window that isn't devtools + .find((webContents) => webContents.getURL().startsWith('file://'))! + .send(event, arg); + }, + { event, arg }, + ); + }; }; -export type MockIpcHandle = ReturnType<typeof generateMockIpcHandle>; +export const createMockIpcHandle = ( + electronApp: ElectronApplication, + event: string, + spec: AnyIpcCall, +) => { + // This function resolves when the handle is registered. To await the event, use `expect()`. + return async <T>(response: T): Promise<void> => { + if ('type' in spec && spec.type === 'send') { + throw new Error(`No value can be returned on a send call (${event})`); + } -export const generateMockIpcHandle = (electronApp: ElectronApplication) => { - return async <T>({ channel, response }: MockIpcHandleProps<T>): Promise<void> => { await electronApp.evaluate( - ({ ipcMain }, { channel, response }) => { - ipcMain.removeHandler(channel); - ipcMain.handle(channel, () => { + ({ ipcMain }, { event, response }) => { + ipcMain.removeHandler(event); + ipcMain.handle(event, () => { return Promise.resolve({ type: 'success', value: response, }); }); }, - { channel, response }, + { event, response }, ); }; }; -type SendMockIpcResponseProps<T> = { - channel: string; - response: T; +// Use when you want to wait for an IPC call to happen but don't need to respond. The returned +// promise resolves when the IPC handle/on methods are triggered triggered. +export const createMockIpcExpect = ( + electronApp: ElectronApplication, + event: string, + spec: AnyIpcCall, +) => { + const type = 'type' in spec ? spec.type : 'invoke'; + + return <T>(): Promise<T> => { + return electronApp.evaluate( + ({ ipcMain }, { event, type }) => { + return new Promise<T>((resolve) => { + if (type === 'send') { + ipcMain.once(event, (_event, arg) => resolve(arg)); + } else { + ipcMain.handleOnce(event, (_event, arg) => { + resolve(arg); + return { + type: 'success', + value: null, + }; + }); + } + }); + }, + { event, type }, + ); + }; }; -export type SendMockIpcResponse = ReturnType<typeof generateSendMockIpcResponse>; +// Use when you knowingly want to ignore when this IPC method is called. Useful to avoid unhandled +// events from being printed and polluting the log output. +export const createMockIpcIgnore = ( + electronApp: ElectronApplication, + event: string, + spec: AnyIpcCall, +) => { + const type = 'type' in spec ? spec.type : 'invoke'; -export const generateSendMockIpcResponse = (electronApp: ElectronApplication) => { - return async <T>({ channel, response }: SendMockIpcResponseProps<T>) => { + return async (): Promise<void> => { await electronApp.evaluate( - ({ webContents }, { channel, response }) => { - webContents - .getAllWebContents() - // Select window that isn't devtools - .find((webContents) => webContents.getURL().startsWith('file://'))! - .send(channel, response); + ({ ipcMain }, { event, type }) => { + if (type === 'send') { + ipcMain.removeAllListeners(event); + ipcMain.addListener(event, () => {}); + } else { + ipcMain.removeHandler(event); + ipcMain.handle(event, () => ({ + type: 'success', + value: null, + })); + } }, - { channel, response }, + { event, type }, ); }; }; -export type ExpectIpcCall = ReturnType<typeof generateExpectIpcCall>; +type IpcMockedTestKey<I extends AnyIpcCall> = I['direction'] extends 'main-to-renderer' + ? 'notify' + : 'handle'; -export const generateExpectIpcCall = (electronApp: ElectronApplication) => { - return <T>(channel: string): Promise<T> => { - return electronApp.evaluate( - ({ ipcMain }, { channel }) => { - return new Promise<T>((resolve) => { - ipcMain.handleOnce(channel, (_event, arg) => { - resolve(arg); - }); - }); - }, - { channel }, - ); +type IpcMockedTestExtraHandlerKey< + I extends AnyIpcCall, + K, +> = I['direction'] extends 'main-to-renderer' ? never : K; + +type IpcMockedTestFn<I extends AnyIpcCall> = I['direction'] extends 'main-to-renderer' + ? Async<NonNullable<ReturnType<I['send']>>> + : Async<Parameters<ReturnType<I['receive']>>[0]>; + +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], 'ignore'>]: () => Promise<void>; + } & { + eventKey: string; + }; }; }; + +export function createTestIpc(electronApp: ElectronApplication): IpcMockedTest<IpcSchema> { + return createIpc(ipcSchema, (event, key, spec) => { + return [ + key, + { + eventKey: event, + notify: createMockIpcNotify(electronApp, event), + handle: createMockIpcHandle(electronApp, event, spec), + expect: createMockIpcExpect(electronApp, event, spec), + ignore: createMockIpcIgnore(electronApp, event, spec), + }, + ]; + }); +} diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/notifications.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/notifications.spec.ts index d432e0df4c..aa0ae73055 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/notifications.spec.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/notifications.spec.ts @@ -3,14 +3,7 @@ import { Page } from 'playwright'; import { getDefaultSettings } from '../../../src/main/default-settings'; import { colorTokens } from '../../../src/renderer/lib/foundations'; -import { - Constraint, - ErrorStateCause, - IAccountData, - IRelayListWithEndpointData, - ISettings, - TunnelState, -} from '../../../src/shared/daemon-rpc-types'; +import { Constraint, ErrorStateCause, TunnelState } from '../../../src/shared/daemon-rpc-types'; import { RoutePath } from '../../../src/shared/routes'; import { getBackgroundColor } from '../utils'; import { MockedTestUtils, startMockedApp } from './mocked-utils'; @@ -31,9 +24,8 @@ test.afterAll(async () => { * Expires soon */ test('App should notify user about account expiring soon', async () => { - await util.sendMockIpcResponse<IAccountData>({ - channel: 'account-', - response: { expiry: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString() }, + await util.ipc.account[''].notify({ + expiry: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), }); const title = page.getByTestId('notificationTitle'); @@ -46,16 +38,14 @@ test('App should notify user about account expiring soon', async () => { const indicatorColor = await getBackgroundColor(indicator); expect(indicatorColor).toBe(colorTokens.yellow); - await util.sendMockIpcResponse<IAccountData>({ - channel: 'account-', - response: { expiry: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString() }, + await util.ipc.account[''].notify({ + expiry: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), }); subTitle = page.getByTestId('notificationSubTitle'); await expect(subTitle).toContainText(/2 days left\. buy more credit\./i); - await util.sendMockIpcResponse<IAccountData>({ - channel: 'account-', - response: { expiry: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toISOString() }, + await util.ipc.account[''].notify({ + expiry: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toISOString(), }); subTitle = page.getByTestId('notificationSubTitle'); await expect(subTitle).toContainText(/less than a day left\. buy more credit\./i); @@ -74,32 +64,23 @@ test.describe('Unsupported wireguard port', () => { if ('normal' in settings.relaySettings) { settings.relaySettings.normal.wireguardConstraints.port = port; } - await util.sendMockIpcResponse<ISettings>({ - channel: 'settings-', - response: settings, - }); + await util.ipc.settings[''].notify(settings); }; const updatePortRanges = async (portRanges: [number, number][]) => { - await util.sendMockIpcResponse<IRelayListWithEndpointData>({ - channel: 'relays-', - response: { - relayList: { - countries: [], - }, - wireguardEndpointData: { - portRanges, - udp2tcpPorts: [], - }, + await util.ipc.relays[''].notify({ + relayList: { + countries: [], + }, + wireguardEndpointData: { + portRanges, + udp2tcpPorts: [], }, }); }; const updateTunnelState = async (tunnelState: TunnelState) => { - await util.sendMockIpcResponse<TunnelState>({ - channel: 'tunnel-', - response: tunnelState, - }); + await util.ipc.tunnel[''].notify(tunnelState); }; test.beforeAll(async () => { diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/helpers.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/helpers.ts index ccfa8289f2..663116643b 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/helpers.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/helpers.ts @@ -8,6 +8,7 @@ import { IRelayListHostname, ISettings, Ownership, + RelaySettings, } from '../../../../src/shared/daemon-rpc-types'; import { RoutePath } from '../../../../src/shared/routes'; import { RoutesObjectModel } from '../../route-object-models'; @@ -107,14 +108,8 @@ export const createHelpers = (page: Page, routes: RoutesObjectModel, utils: Mock settings.relaySettings.normal.providers = providers; } } - await utils.mockIpcHandle({ - channel: 'settings-setRelaySettings', - response: {}, - }); - await utils.sendMockIpcResponse({ - channel: 'settings-', - response: settings, - }); + await utils.ipc.settings.setRelaySettings.handle({} as RelaySettings); + await utils.ipc.settings[''].notify(settings); }; const updateMockSettings = async ( @@ -139,10 +134,7 @@ export const createHelpers = (page: Page, routes: RoutesObjectModel, utils: Mock if (directOnly !== undefined) settings.tunnelOptions.wireguard.daita.directOnly = directOnly; } - await utils.sendMockIpcResponse<ISettings>({ - channel: 'settings-', - response: settings, - }); + await utils.ipc.settings[''].notify(settings); return settings; }; @@ -161,10 +153,7 @@ export const createHelpers = (page: Page, routes: RoutesObjectModel, utils: Mock }; } - await utils.sendMockIpcResponse<ISettings>({ - channel: 'settings-', - response: settings, - }); + await utils.ipc.settings[''].notify(settings); return settings; }; diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/select-location.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/select-location.spec.ts index 2c0a413522..02a1a403bc 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/select-location.spec.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/select-location.spec.ts @@ -3,7 +3,7 @@ import { Page } from 'playwright'; import { getDefaultSettings } from '../../../../src/main/default-settings'; import { colorTokens } from '../../../../src/renderer/lib/foundations'; -import { ISettings, ObfuscationType, Ownership } from '../../../../src/shared/daemon-rpc-types'; +import { ObfuscationType, Ownership } from '../../../../src/shared/daemon-rpc-types'; import { RoutePath } from '../../../../src/shared/routes'; import { mockData } from '../../mock-data'; import { RoutesObjectModel } from '../../route-object-models'; @@ -125,6 +125,9 @@ test.describe('Select location', () => { }); test('Should disable entry server in exit list', async () => { + await util.ipc.tunnel.connect.ignore(); + await util.ipc.settings.setRelaySettings.ignore(); + const settings = await helpers.updateMockSettings({ multihop: true, daita: true, @@ -279,10 +282,8 @@ test.describe('Select location', () => { if ('normal' in settings.relaySettings) { settings.obfuscationSettings.selectedObfuscation = ObfuscationType.quic; } - await util.sendMockIpcResponse<ISettings>({ - channel: 'settings-', - response: settings, - }); + await util.ipc.settings[''].notify(settings); + const locatedRelays = helpers.locateRelaysByObfuscation(relayList); const relays = locatedRelays.map((locatedRelay) => locatedRelay.relay); const relayNames = relays.map((relay) => relay.hostname); diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/settings.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/settings.spec.ts index 472893a5c0..8648cf4e69 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/settings.spec.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/settings.spec.ts @@ -1,7 +1,6 @@ import { expect, test } from '@playwright/test'; import { Page } from 'playwright'; -import { IAccountData } from '../../../src/shared/daemon-rpc-types'; import { RoutePath } from '../../../src/shared/routes'; import { MockedTestUtils, startMockedApp } from './mocked-utils'; @@ -30,27 +29,24 @@ test('Headerbar account info should be displayed correctly', async () => { * 729 days left * Add a one-second margin to the test, since it randomly fails in Github Actions otherwise */ - await util.sendMockIpcResponse<IAccountData>({ - channel: 'account-', - response: { expiry: new Date(Date.now() + 730 * 24 * 60 * 60 * 1000 - 1000).toISOString() }, + await util.ipc.account[''].notify({ + expiry: new Date(Date.now() + 730 * 24 * 60 * 60 * 1000 - 1000).toISOString(), }); await expect(expiryText).toContainText(/Time left: 729 days/i); /** * 2 years left */ - await util.sendMockIpcResponse<IAccountData>({ - channel: 'account-', - response: { expiry: new Date(Date.now() + 731 * 24 * 60 * 60 * 1000).toISOString() }, + await util.ipc.account[''].notify({ + expiry: new Date(Date.now() + 731 * 24 * 60 * 60 * 1000).toISOString(), }); await expect(expiryText).toContainText(/Time left: 2 years/i); /** * Expiry 1 day ago should show 'out of time' */ - await util.sendMockIpcResponse<IAccountData>({ - channel: 'account-', - response: { expiry: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString() }, + await util.ipc.account[''].notify({ + expiry: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), }); await expect(expiryText).not.toBeVisible(); }); diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/tunnel-state.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/tunnel-state.spec.ts index e8a706471b..d1790925c9 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/tunnel-state.spec.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/tunnel-state.spec.ts @@ -1,12 +1,7 @@ import { test } from '@playwright/test'; import { Page } from 'playwright'; -import { - ErrorStateCause, - ILocation, - ITunnelEndpoint, - TunnelState, -} from '../../../src/shared/daemon-rpc-types'; +import { ErrorStateCause, ILocation, ITunnelEndpoint } from '../../../src/shared/daemon-rpc-types'; import { RoutePath } from '../../../src/shared/routes'; import { expectConnected, @@ -41,14 +36,7 @@ test.afterAll(async () => { * Disconnected state */ test('App should show disconnected tunnel state', async () => { - await util.mockIpcHandle<ILocation>({ - channel: 'location-get', - response: mockLocation, - }); - await util.sendMockIpcResponse<TunnelState>({ - channel: 'tunnel-', - response: { state: 'disconnected', lockedDown: false }, - }); + await util.ipc.tunnel[''].notify({ state: 'disconnected', lockedDown: false }); await expectDisconnected(page); }); @@ -56,14 +44,7 @@ test('App should show disconnected tunnel state', async () => { * Connecting state */ test('App should show connecting tunnel state', async () => { - await util.mockIpcHandle<ILocation>({ - channel: 'location-get', - response: mockLocation, - }); - await util.sendMockIpcResponse<TunnelState>({ - channel: 'tunnel-', - response: { state: 'connecting', featureIndicators: undefined }, - }); + await util.ipc.tunnel[''].notify({ state: 'connecting', featureIndicators: undefined }); await expectConnecting(page); }); @@ -72,10 +53,6 @@ test('App should show connecting tunnel state', async () => { */ test('App should show connected tunnel state', async () => { const location: ILocation = { ...mockLocation, mullvadExitIp: true }; - await util.mockIpcHandle<ILocation>({ - channel: 'location-get', - response: location, - }); const endpoint: ITunnelEndpoint = { address: 'wg10:80', @@ -84,9 +61,10 @@ test('App should show connected tunnel state', async () => { tunnelType: 'wireguard', daita: false, }; - await util.sendMockIpcResponse<TunnelState>({ - channel: 'tunnel-', - response: { state: 'connected', details: { endpoint, location }, featureIndicators: undefined }, + await util.ipc.tunnel[''].notify({ + state: 'connected', + details: { endpoint, location }, + featureIndicators: undefined, }); await expectConnected(page); @@ -96,14 +74,7 @@ test('App should show connected tunnel state', async () => { * Disconnecting state */ test('App should show disconnecting tunnel state', async () => { - await util.mockIpcHandle<ILocation>({ - channel: 'location-get', - response: mockLocation, - }); - await util.sendMockIpcResponse<TunnelState>({ - channel: 'tunnel-', - response: { state: 'disconnecting', details: 'nothing' }, - }); + await util.ipc.tunnel[''].notify({ state: 'disconnecting', details: 'nothing' }); await expectDisconnecting(page); }); @@ -111,13 +82,9 @@ test('App should show disconnecting tunnel state', async () => { * Error state */ test('App should show error tunnel state', async () => { - await util.mockIpcHandle<ILocation>({ - channel: 'location-get', - response: mockLocation, - }); - await util.sendMockIpcResponse<TunnelState>({ - channel: 'tunnel-', - response: { state: 'error', details: { cause: ErrorStateCause.isOffline } }, + await util.ipc.tunnel[''].notify({ + state: 'error', + details: { cause: ErrorStateCause.isOffline }, }); await expectError(page); }); diff --git a/desktop/packages/mullvad-vpn/test/e2e/setup/main.ts b/desktop/packages/mullvad-vpn/test/e2e/setup/main.ts index de46f5e578..37bc6cdb6f 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/setup/main.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/setup/main.ts @@ -114,6 +114,10 @@ class ApplicationMain { await window.loadFile(path.join(__dirname, 'index.html')); + if (process.argv.includes('--show-window')) { + window.show(); + } + if (DEBUG) { window.webContents.openDevTools({ mode: 'detach' }); } |
