diff options
| author | Hank <hank@mullvad.net> | 2022-09-20 11:37:37 +0200 |
|---|---|---|
| committer | Hank <hank@mullvad.net> | 2022-09-20 11:37:37 +0200 |
| commit | c7b7e13059c1d839d41d2dd8728235111ad648dd (patch) | |
| tree | 436acdc89b109c442eb047b6bcd97e294731c0c8 | |
| parent | 5e04ba5fdf766747a993a2182e5f3ed6298b4b1a (diff) | |
| parent | 162d1e392306c283c7d06def2884471a5e19fca4 (diff) | |
| download | mullvadvpn-c7b7e13059c1d839d41d2dd8728235111ad648dd.tar.xz mullvadvpn-c7b7e13059c1d839d41d2dd8728235111ad648dd.zip | |
Merge branch 'e2e'
| -rw-r--r-- | .github/workflows/frontend.yml | 3 | ||||
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | gui/.eslintignore | 4 | ||||
| -rw-r--r-- | gui/.eslintrc.js | 1 | ||||
| -rw-r--r-- | gui/package-lock.json | 97 | ||||
| -rw-r--r-- | gui/package.json | 11 | ||||
| -rw-r--r-- | gui/playwright.config.ts | 18 | ||||
| -rw-r--r-- | gui/src/main/relay-list.ts | 8 | ||||
| -rw-r--r-- | gui/src/renderer/.eslintignore | 1 | ||||
| -rw-r--r-- | gui/src/renderer/preload.ts | 1 | ||||
| -rw-r--r-- | gui/src/renderer/redux/store.ts | 17 | ||||
| -rw-r--r-- | gui/tasks/distribution.js | 2 | ||||
| -rw-r--r-- | gui/test/e2e/main.spec.ts | 21 | ||||
| -rw-r--r-- | gui/test/e2e/settings.spec.ts | 24 | ||||
| -rw-r--r-- | gui/test/e2e/setup/main.ts | 195 | ||||
| -rw-r--r-- | gui/test/e2e/tunnel-state.spec.ts | 179 | ||||
| -rw-r--r-- | gui/test/e2e/utils.ts | 80 | ||||
| -rw-r--r-- | gui/test/unit/account-data-cache.spec.ts (renamed from gui/test/account-data-cache.spec.ts) | 4 | ||||
| -rw-r--r-- | gui/test/unit/auth-failure.spec.ts (renamed from gui/test/auth-failure.spec.ts) | 2 | ||||
| -rw-r--r-- | gui/test/unit/date-helper.spec.ts (renamed from gui/test/date-helper.spec.ts) | 2 | ||||
| -rw-r--r-- | gui/test/unit/history.spec.ts (renamed from gui/test/history.spec.ts) | 4 | ||||
| -rw-r--r-- | gui/test/unit/ip.spec.ts (renamed from gui/test/ip.spec.ts) | 2 | ||||
| -rw-r--r-- | gui/test/unit/keyframe-animation.spec.ts (renamed from gui/test/keyframe-animation.spec.ts) | 2 | ||||
| -rw-r--r-- | gui/test/unit/list-diff.spec.ts (renamed from gui/test/list-diff.spec.ts) | 2 | ||||
| -rw-r--r-- | gui/test/unit/logging.spec.ts (renamed from gui/test/logging.spec.ts) | 6 | ||||
| -rw-r--r-- | gui/test/unit/relay-settings-builder.spec.ts (renamed from gui/test/relay-settings-builder.spec.ts) | 2 | ||||
| -rw-r--r-- | gui/test/unit/setup/changelog.spec.ts (renamed from gui/test/setup/changelog.spec.ts) | 2 | ||||
| -rw-r--r-- | gui/test/unit/setup/renderer.ts (renamed from gui/test/setup/renderer.ts) | 0 | ||||
| -rw-r--r-- | gui/test/unit/tunnel-state.spec.ts (renamed from gui/test/tunnel-state.spec.ts) | 4 | ||||
| -rw-r--r-- | gui/tsconfig.json | 3 | ||||
| -rw-r--r-- | gui/types/global/index.d.ts | 2 |
31 files changed, 670 insertions, 32 deletions
diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 6cd1e415c6..203ac0f724 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -53,3 +53,6 @@ jobs: working-directory: gui run: npm test + - name: Run Playwright tests + working-directory: gui + run: npm run e2e:no-build diff --git a/.gitignore b/.gitignore index 163b0ac9da..170e4589b4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,11 @@ /gui/scripts/ne_50m_admin_1_states_provinces_lines/ /gui/scripts/out/ /gui/src/main/management_interface/ +/gui/test/e2e/screenshots/ +/gui/test-results/ /build/ /dist +.idea/ .DS_Store *.log /dist-assets/relays.json diff --git a/gui/.eslintignore b/gui/.eslintignore new file mode 100644 index 0000000000..07b7d976c2 --- /dev/null +++ b/gui/.eslintignore @@ -0,0 +1,4 @@ +.eslintrc.js +gulpfile.js +init.js +tasks/*.js diff --git a/gui/.eslintrc.js b/gui/.eslintrc.js index a4b2d46a28..c2d392fbfb 100644 --- a/gui/.eslintrc.js +++ b/gui/.eslintrc.js @@ -57,6 +57,7 @@ module.exports = { parserOptions: { parser: '@typescript-eslint/parser', project: './tsconfig.json', + tsconfigRootDir: __dirname, ecmaVersion: '2018', sourceType: 'module', ecmaFeatures: { diff --git a/gui/package-lock.json b/gui/package-lock.json index bda9bbaab7..bb4cbaadf5 100644 --- a/gui/package-lock.json +++ b/gui/package-lock.json @@ -27,6 +27,7 @@ "styled-components": "^5.1.1" }, "devDependencies": { + "@playwright/test": "^1.25.1", "@types/chai": "^4.1.7", "@types/chai-as-promised": "^7.1.0", "@types/chai-spies": "^1.0.0", @@ -69,6 +70,7 @@ "gulp-inject-string": "^1.1.2", "gulp-sourcemaps": "^2.6.5", "gulp-typescript": "^5.0.1", + "playwright": "^1.25.1", "prettier": "^2.2.1", "semver": "^7.3.2", "sinon": "^7.1.1", @@ -76,7 +78,8 @@ "tsc-watch": "^4.2.9", "typescript": "^4.5.4", "vinyl-buffer": "^1.0.1", - "vinyl-source-stream": "^2.0.0" + "vinyl-source-stream": "^2.0.0", + "xvfb-maybe": "^0.2.1" }, "engines": { "node": ">=16", @@ -1055,6 +1058,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.25.1.tgz", + "integrity": "sha512-IJ4X0yOakXtwkhbnNzKkaIgXe6df7u3H3FnuhI9Jqh+CdO0e/lYQlDLYiyI9cnXK8E7UAppAWP+VqAv6VX7HQg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "playwright-core": "1.25.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -10807,6 +10826,34 @@ "node": ">=0.10.0" } }, + "node_modules/playwright": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.25.1.tgz", + "integrity": "sha512-kOlW7mllnQ70ALTwAor73q/FhdH9EEXLUqjdzqioYLcSVC4n4NBfDqeCikGuayFZrLECLkU6Hcbziy/szqTXSA==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "playwright-core": "1.25.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/playwright-core": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.25.1.tgz", + "integrity": "sha512-lSvPCmA2n7LawD2Hw7gSCLScZ+vYRkhU8xH0AapMyzwN+ojoDqhkH/KIEUxwNu2PjPoE/fcE0wLAksdOhJ2O5g==", + "dev": true, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/plist": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/plist/-/plist-3.0.6.tgz", @@ -14024,6 +14071,19 @@ "node": ">=0.4" } }, + "node_modules/xvfb-maybe": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/xvfb-maybe/-/xvfb-maybe-0.2.1.tgz", + "integrity": "sha512-9IyRz3l6Qyhl6LvnGRF5jMPB4oBEepQnuzvVAFTynP6ACLLSevqigICJ9d/+ofl29m2daeaVBChnPYUnaeJ7yA==", + "dev": true, + "dependencies": { + "debug": "^2.2.0", + "which": "^1.2.4" + }, + "bin": { + "xvfb-maybe": "src/xvfb-maybe.js" + } + }, "node_modules/y18n": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", @@ -15017,6 +15077,16 @@ "fastq": "^1.6.0" } }, + "@playwright/test": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.25.1.tgz", + "integrity": "sha512-IJ4X0yOakXtwkhbnNzKkaIgXe6df7u3H3FnuhI9Jqh+CdO0e/lYQlDLYiyI9cnXK8E7UAppAWP+VqAv6VX7HQg==", + "dev": true, + "requires": { + "@types/node": "*", + "playwright-core": "1.25.1" + } + }, "@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -22854,6 +22924,21 @@ "pinkie": "^2.0.0" } }, + "playwright": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.25.1.tgz", + "integrity": "sha512-kOlW7mllnQ70ALTwAor73q/FhdH9EEXLUqjdzqioYLcSVC4n4NBfDqeCikGuayFZrLECLkU6Hcbziy/szqTXSA==", + "dev": true, + "requires": { + "playwright-core": "1.25.1" + } + }, + "playwright-core": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.25.1.tgz", + "integrity": "sha512-lSvPCmA2n7LawD2Hw7gSCLScZ+vYRkhU8xH0AapMyzwN+ojoDqhkH/KIEUxwNu2PjPoE/fcE0wLAksdOhJ2O5g==", + "dev": true + }, "plist": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/plist/-/plist-3.0.6.tgz", @@ -25461,6 +25546,16 @@ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "dev": true }, + "xvfb-maybe": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/xvfb-maybe/-/xvfb-maybe-0.2.1.tgz", + "integrity": "sha512-9IyRz3l6Qyhl6LvnGRF5jMPB4oBEepQnuzvVAFTynP6ACLLSevqigICJ9d/+ofl29m2daeaVBChnPYUnaeJ7yA==", + "dev": true, + "requires": { + "debug": "^2.2.0", + "which": "^1.2.4" + } + }, "y18n": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", diff --git a/gui/package.json b/gui/package.json index cf2efa49de..7294f89490 100644 --- a/gui/package.json +++ b/gui/package.json @@ -33,6 +33,7 @@ "nseventmonitor": "^1.0.2" }, "devDependencies": { + "@playwright/test": "^1.25.1", "@types/chai": "^4.1.7", "@types/chai-as-promised": "^7.1.0", "@types/chai-spies": "^1.0.0", @@ -75,6 +76,7 @@ "gulp-inject-string": "^1.1.2", "gulp-sourcemaps": "^2.6.5", "gulp-typescript": "^5.0.1", + "playwright": "^1.25.1", "prettier": "^2.2.1", "semver": "^7.3.2", "sinon": "^7.1.1", @@ -82,7 +84,8 @@ "tsc-watch": "^4.2.9", "typescript": "^4.5.4", "vinyl-buffer": "^1.0.1", - "vinyl-source-stream": "^2.0.0" + "vinyl-source-stream": "^2.0.0", + "xvfb-maybe": "^0.2.1" }, "scripts": { "postinstall": "cross-env ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES=true electron-builder install-app-deps", @@ -90,8 +93,12 @@ "build-proto": "gulp build-proto", "lint": "eslint --ext tsx,ts .", "format": "prettier \"**/*.{js,css,ts,tsx}\" --write", + "tsc": "tsc -p . --noEmit", + "e2e": "npm run build && npm run e2e:no-build", + "e2e:no-build": "xvfb-maybe -- playwright test", + "e2e:update-snapshots": "npm run e2e:no-build -- --update-snapshots", "develop": "gulp develop", - "test": "cross-env NODE_ENV=test electron-mocha --renderer --reporter spec --require ts-node/register --require \"test/setup/renderer.ts\" \"test/**/*.{ts,tsx}\"", + "test": "cross-env NODE_ENV=test electron-mocha --renderer --reporter spec --require ts-node/register --require \"test/unit/setup/renderer.ts\" \"test/unit/**/*.{ts,tsx}\"", "update-translations": "node scripts/extract-translations", "pack:mac": "gulp pack-mac", "pack:win": "gulp pack-win", diff --git a/gui/playwright.config.ts b/gui/playwright.config.ts new file mode 100644 index 0000000000..025675c831 --- /dev/null +++ b/gui/playwright.config.ts @@ -0,0 +1,18 @@ +import { PlaywrightTestConfig } from '@playwright/test'; +const config: PlaywrightTestConfig = { + testDir: './test/e2e', + maxFailures: 2, + timeout: 60000, + expect: { + toMatchSnapshot: { + threshold: 0.1, + maxDiffPixelRatio: 0.01, + }, + }, + use: { + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, +}; + +export default config; diff --git a/gui/src/main/relay-list.ts b/gui/src/main/relay-list.ts index 8583f58776..b43548e6a1 100644 --- a/gui/src/main/relay-list.ts +++ b/gui/src/main/relay-list.ts @@ -1,11 +1,7 @@ import { BridgeState, IRelayList, liftConstraint, RelaySettings } from '../shared/daemon-rpc-types'; +import { IRelayListPair } from '../shared/ipc-schema'; import { IpcMainEventChannel } from './ipc-event-channel'; -interface RelayLists { - relays: IRelayList; - bridges: IRelayList; -} - export default class RelayList { private relays: IRelayList = { countries: [] }; @@ -32,7 +28,7 @@ export default class RelayList { relayList: IRelayList, relaySettings: RelaySettings, bridgeState: BridgeState, - ): RelayLists { + ): IRelayListPair { const filteredRelays = this.processRelaysForPresentation(relayList, relaySettings); const filteredBridges = this.processBridgesForPresentation(relayList, bridgeState); diff --git a/gui/src/renderer/.eslintignore b/gui/src/renderer/.eslintignore new file mode 100644 index 0000000000..a9ba028cee --- /dev/null +++ b/gui/src/renderer/.eslintignore @@ -0,0 +1 @@ +.eslintrc.js diff --git a/gui/src/renderer/preload.ts b/gui/src/renderer/preload.ts index 8f37905ece..61b92a5962 100644 --- a/gui/src/renderer/preload.ts +++ b/gui/src/renderer/preload.ts @@ -5,6 +5,7 @@ import { IpcRendererEventChannel } from './lib/ipc-event-channel'; contextBridge.exposeInMainWorld('ipc', IpcRendererEventChannel); contextBridge.exposeInMainWorld('env', { + e2e: process.env.CI, development: process.env.NODE_ENV === 'development', platform: process.platform, }); diff --git a/gui/src/renderer/redux/store.ts b/gui/src/renderer/redux/store.ts index d0969dbf85..9634774038 100644 --- a/gui/src/renderer/redux/store.ts +++ b/gui/src/renderer/redux/store.ts @@ -1,6 +1,6 @@ import { useRef } from 'react'; import { useSelector as useReduxSelector } from 'react-redux'; -import { combineReducers, compose, createStore, Dispatch } from 'redux'; +import { combineReducers, compose, createStore, Dispatch, StoreEnhancer } from 'redux'; import { useWillExit } from '../lib/will-exit'; import accountActions, { AccountAction } from './account/actions'; @@ -50,7 +50,7 @@ export default function configureStore() { return createStore(rootReducer, composeEnhancers()); } -function composeEnhancers(): typeof compose { +function composeEnhancers(): StoreEnhancer { const actionCreators = { ...accountActions, ...connectionActions, @@ -60,10 +60,15 @@ function composeEnhancers(): typeof compose { ...userInterfaceActions, }; - return window.env.development - ? // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ actionCreators })() - : compose(); + if (window.env.development) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const devtoolsCompose = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__?.({ + actionCreators, + }); + return devtoolsCompose ? devtoolsCompose() : compose(); + } + + return compose(); } // This hook adds type to state to make use simpler. It also prevents the state from update if the diff --git a/gui/tasks/distribution.js b/gui/tasks/distribution.js index 7152eb4b8a..b778a57b8c 100644 --- a/gui/tasks/distribution.js +++ b/gui/tasks/distribution.js @@ -50,6 +50,8 @@ const config = { 'build/src/renderer/bundle.js', 'build/src/renderer/preloadBundle.js', '!**/*.tsbuildinfo', + '!test/', + '!playwright.config.ts', 'node_modules/', '!node_modules/grpc-tools', '!node_modules/@types', diff --git a/gui/test/e2e/main.spec.ts b/gui/test/e2e/main.spec.ts new file mode 100644 index 0000000000..85f69f9cf6 --- /dev/null +++ b/gui/test/e2e/main.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test'; +import { Page } from 'playwright'; + +import { startApp } from './utils'; + +let appWindow: Page; + +test.beforeAll(async () => { + const startAppResponse = await startApp(); + appWindow = startAppResponse.appWindow; +}); + +test.afterAll(async () => { + await appWindow.close(); +}); + +test('Validate title', async () => { + const title = await appWindow.title(); + expect(title).toBe('Mullvad VPN'); + await expect(appWindow.locator('header')).toBeVisible(); +}); diff --git a/gui/test/e2e/settings.spec.ts b/gui/test/e2e/settings.spec.ts new file mode 100644 index 0000000000..6e6aef1d4b --- /dev/null +++ b/gui/test/e2e/settings.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; +import { Page } from 'playwright'; + +import { startApp } from './utils'; + +let appWindow: Page; + +test.beforeAll(async () => { + const startAppResponse = await startApp(); + appWindow = startAppResponse.appWindow; + await appWindow.click('button[aria-label="Settings"]'); +}); + +test.afterAll(async () => { + await appWindow.close(); +}); + +test('Settings Page', async () => { + const title = await appWindow.locator('h1'); + await expect(title).toContainText('Settings'); + + const closeButton = await appWindow.locator('button[aria-label="Close"]'); + await expect(closeButton).toBeVisible(); +}); diff --git a/gui/test/e2e/setup/main.ts b/gui/test/e2e/setup/main.ts new file mode 100644 index 0000000000..f65169ee44 --- /dev/null +++ b/gui/test/e2e/setup/main.ts @@ -0,0 +1,195 @@ +import { app, BrowserWindow } from 'electron'; +import * as path from 'path'; + +import { getDefaultSettings } from '../../../src/main/default-settings'; +import { changeIpcWebContents, IpcMainEventChannel } from '../../../src/main/ipc-event-channel'; +import { loadTranslations } from '../../../src/main/load-translations'; +import { + DeviceState, + IAccountData, + IAppVersionInfo, + ILocation, + IRelayList, +} from '../../../src/shared/daemon-rpc-types'; +import { messages, relayLocations } from '../../../src/shared/gettext'; +import { IGuiSettingsState } from '../../../src/shared/gui-settings-state'; +import { ITranslations, MacOsScrollbarVisibility } from '../../../src/shared/ipc-schema'; +import { ICurrentAppVersionInfo } from '../../../src/shared/ipc-types'; + +const DEBUG = false; + +class ApplicationMain { + private guiSettings: IGuiSettingsState = { + preferredLocale: 'en', + autoConnect: false, + enableSystemNotifications: true, + monochromaticIcon: false, + startMinimized: false, + unpinnedWindow: process.platform !== 'win32' && process.platform !== 'darwin', + browsedForSplitTunnelingApplications: [], + changelogDisplayedForVersion: '', + }; + + private settings = getDefaultSettings(); + + private translations: ITranslations = { locale: this.guiSettings.preferredLocale }; + + private isConnectedToDaemon = true; + + private accountData: IAccountData = { + expiry: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + }; + + private deviceState: DeviceState = { + type: 'logged in', + accountAndDevice: { + accountToken: '1234123412341234', + device: { + id: '1234', + name: 'Testing Mole', + ports: [], + created: new Date(), + }, + }, + }; + + private currentVersion: ICurrentAppVersionInfo = { + gui: '2000.1', + daemon: '2000.1', + isConsistent: true, + isBeta: false, + }; + private upgradeVersion: IAppVersionInfo = { + supported: true, + suggestedUpgrade: undefined, + }; + + private location: ILocation = { + country: 'Sweden', + city: 'Gothenburg', + latitude: 58, + longitude: 12, + mullvadExitIp: false, + }; + + private relayList: IRelayList = { + countries: [ + { + name: 'Sweden', + code: 'se', + cities: [ + { + name: 'Gothenburg', + code: 'got', + latitude: 58, + longitude: 12, + relays: [ + { + hostname: 'se-got-wg-101', + provider: 'mullvad', + ipv4AddrIn: '127.0.0.1', + includeInCountry: true, + active: true, + weight: 0, + owned: true, + endpointType: 'wireguard', + }, + ], + }, + ], + }, + ], + }; + + public constructor() { + app.enableSandbox(); + app.on('ready', this.onReady); + } + + private onReady = async () => { + this.updateCurrentLocale('en'); + + const window = new BrowserWindow({ + useContentSize: true, + width: 320, + height: 568, + resizable: false, + maximizable: false, + fullscreenable: false, + show: DEBUG, + frame: true, + webPreferences: { + preload: path.join(__dirname, '../../../src/renderer/preloadBundle.js'), + nodeIntegration: false, + nodeIntegrationInWorker: false, + nodeIntegrationInSubFrames: false, + sandbox: true, + contextIsolation: true, + spellcheck: false, + devTools: DEBUG, + }, + }); + + changeIpcWebContents(window.webContents); + + this.registerIpcListeners(); + + // @ts-ignore + const filePath = path.resolve(path.join(__dirname, '../../../src/renderer/index.html')); + await window.loadFile(filePath); + + if (DEBUG) { + window.webContents.openDevTools({ mode: 'detach' }); + } + }; + + private registerIpcListeners() { + IpcMainEventChannel.state.handleGet(() => ({ + isConnected: this.isConnectedToDaemon, + autoStart: false, + accountData: this.accountData, + accountHistory: undefined, + tunnelState: { state: 'disconnected' }, + settings: this.settings, + isPerformingPostUpgrade: false, + deviceState: this.deviceState, + relayListPair: { relays: this.relayList, bridges: this.relayList }, + currentVersion: this.currentVersion, + upgradeVersion: this.upgradeVersion, + guiSettings: this.guiSettings, + translations: this.translations, + windowsSplitTunnelingApplications: [], + macOsScrollbarVisibility: MacOsScrollbarVisibility.whenScrolling, + changelog: [], + forceShowChanges: false, + navigationHistory: undefined, + scrollPositions: {}, + })); + + IpcMainEventChannel.location.handleGet(() => Promise.resolve(this.location)); + + IpcMainEventChannel.guiSettings.handleSetPreferredLocale((locale) => { + this.updateCurrentLocale(locale); + IpcMainEventChannel.guiSettings.notify?.(this.guiSettings); + return Promise.resolve(this.translations); + }); + } + + private updateCurrentLocale(locale: string) { + this.guiSettings.preferredLocale = locale; + + const messagesTranslations = loadTranslations(this.guiSettings.preferredLocale, messages); + const relayLocationsTranslations = loadTranslations( + this.guiSettings.preferredLocale, + relayLocations, + ); + + this.translations = { + locale: this.guiSettings.preferredLocale, + messages: messagesTranslations, + relayLocations: relayLocationsTranslations, + }; + } +} + +new ApplicationMain(); diff --git a/gui/test/e2e/tunnel-state.spec.ts b/gui/test/e2e/tunnel-state.spec.ts new file mode 100644 index 0000000000..1931beebb9 --- /dev/null +++ b/gui/test/e2e/tunnel-state.spec.ts @@ -0,0 +1,179 @@ +import { expect, test } from '@playwright/test'; +import { Page } from 'playwright'; + +import { colors } from '../../src/config.json'; +import { ILocation, ITunnelEndpoint, TunnelState } from '../../src/shared/daemon-rpc-types'; +import { + getBackgroundColor, + getColor, + mockIpcHandle, + sendMockIpcResponse, + startApp, +} from './utils'; + +const UNSECURED_COLOR = colors.red; +const SECURE_COLOR = colors.green; +const WHITE_COLOR = colors.white; + +const mockLocation: ILocation = { + country: 'Sweden', + city: 'Gothenburg', + latitude: 58, + longitude: 12, + mullvadExitIp: false, +}; + +const getLabel = () => appWindow.locator('span[role="status"]'); +const getHeader = () => appWindow.locator('header'); + +let appWindow: Page; + +test.beforeAll(async () => { + const startAppResponse = await startApp(); + appWindow = startAppResponse.appWindow; +}); + +test.afterAll(async () => { + await appWindow.close(); +}); + +/** + * Disconnected state + */ +test('App should show disconnected tunnel state', async () => { + await mockIpcHandle<ILocation>({ + channel: 'location-get', + response: mockLocation, + }); + + await sendMockIpcResponse<TunnelState>({ + channel: 'tunnel-', + response: { state: 'disconnected' }, + }); + + const statusLabel = getLabel(); + await expect(statusLabel).toContainText(/unsecured connection/i); + const labelColor = await getColor(statusLabel); + expect(labelColor).toBe(UNSECURED_COLOR); + + const header = getHeader(); + const headerColor = await getBackgroundColor(header); + expect(headerColor).toBe(UNSECURED_COLOR); + + const button = await appWindow.locator('button', { hasText: /secure my connection/i }); + const buttonColor = await getBackgroundColor(button); + expect(buttonColor).toBe(SECURE_COLOR); +}); + +/** + * Connecting state + */ +test('App should show connecting tunnel state', async () => { + await mockIpcHandle<ILocation>({ + channel: 'location-get', + response: mockLocation, + }); + + await sendMockIpcResponse<TunnelState>({ + channel: 'tunnel-', + response: { state: 'connecting' }, + }); + + const statusLabel = getLabel(); + await expect(statusLabel).toContainText(/creating secure connection/i); + const labelColor = await getColor(statusLabel); + expect(labelColor).toBe(WHITE_COLOR); + + const header = getHeader(); + const headerColor = await getBackgroundColor(header); + expect(headerColor).toBe(SECURE_COLOR); + + const button = await appWindow.locator('button', { hasText: /cancel/i }); + const buttonColor = await getBackgroundColor(button); + expect(buttonColor).toBe('rgba(227, 64, 57, 0.6)'); +}); + +/** + * Connected state + */ +test('App should show connected tunnel state', async () => { + const location: ILocation = { ...mockLocation, mullvadExitIp: true }; + await mockIpcHandle<ILocation>({ + channel: 'location-get', + response: location, + }); + + const endpoint: ITunnelEndpoint = { + address: 'wg10:80', + protocol: 'tcp', + quantumResistant: false, + tunnelType: 'wireguard', + }; + await sendMockIpcResponse<TunnelState>({ + channel: 'tunnel-', + response: { state: 'connected', details: { endpoint, location } }, + }); + + const statusLabel = getLabel(); + await expect(statusLabel).toContainText(/secure connection/i); + const labelColor = await getColor(statusLabel); + expect(labelColor).toBe(SECURE_COLOR); + + const header = getHeader(); + const headerColor = await getBackgroundColor(header); + expect(headerColor).toBe(SECURE_COLOR); + + const button = await appWindow.locator('button', { hasText: /switch location/i }); + const buttonColor = await getBackgroundColor(button); + expect(buttonColor).toBe('rgba(255, 255, 255, 0.2)'); +}); + +/** + * Disconnecting state + */ +test('App should show disconnecting tunnel state', async () => { + await mockIpcHandle<ILocation>({ + channel: 'location-get', + response: mockLocation, + }); + + await sendMockIpcResponse<TunnelState>({ + channel: 'tunnel-', + response: { state: 'disconnecting', details: 'nothing' }, + }); + + const statusLabel = getLabel(); + await expect(statusLabel).toBeEmpty(); + + const header = getHeader(); + const headerColor = await getBackgroundColor(header); + expect(headerColor).toBe(UNSECURED_COLOR); + + const button = await appWindow.locator('button', { hasText: /secure my connection/i }); + const buttonColor = await getBackgroundColor(button); + expect(buttonColor).toBe(SECURE_COLOR); +}); + +/** + * Error state + */ +test('App should show error tunnel state', async () => { + await mockIpcHandle<ILocation>({ + channel: 'location-get', + response: mockLocation, + }); + + await sendMockIpcResponse<TunnelState>({ + channel: 'tunnel-', + response: { state: 'error', details: { cause: { reason: 'is_offline' } } }, + }); + + const statusLabel = getLabel(); + await expect(statusLabel).toContainText(/blocked connection/i); + const labelColor = await getColor(statusLabel); + expect(labelColor).toBe(WHITE_COLOR); + + const header = getHeader(); + const headerColor = await getBackgroundColor(header); + expect(headerColor).toBe(SECURE_COLOR); +}); diff --git a/gui/test/e2e/utils.ts b/gui/test/e2e/utils.ts new file mode 100644 index 0000000000..c8d75f6119 --- /dev/null +++ b/gui/test/e2e/utils.ts @@ -0,0 +1,80 @@ +import { Locator, Page } from 'playwright'; +import { _electron as electron, ElectronApplication } from 'playwright-core'; + +interface StartAppResponse { + electronApp: ElectronApplication; + appWindow: Page; +} + +let electronApp: ElectronApplication; + +export const startApp = async (): Promise<StartAppResponse> => { + process.env.CI = 'e2e'; + + electronApp = await electron.launch({ + args: ['build/test/e2e/setup/main.js'], + }); + + const appWindow = await electronApp.firstWindow(); + + appWindow.on('pageerror', (error) => { + console.log(error); + }); + + appWindow.on('console', (msg) => { + console.log(msg.text()); + }); + + return { electronApp, appWindow }; +}; + +type MockIpcHandleProps<T> = { + channel: string; + response: T; +}; + +export const mockIpcHandle = async <T>({ channel, response }: MockIpcHandleProps<T>) => { + await electronApp.evaluate( + ({ ipcMain }, { channel, response }) => { + ipcMain.removeHandler(channel); + ipcMain.handle(channel, () => { + return Promise.resolve({ + type: 'success', + value: response, + }); + }); + }, + { channel, response }, + ); +}; + +type SendMockIpcResponseProps<T> = { + channel: string; + response: T; +}; + +export const sendMockIpcResponse = async <T>({ channel, response }: SendMockIpcResponseProps<T>) => { + await electronApp.evaluate( + ({ webContents }, { channel, response }) => { + webContents.getAllWebContents()[0].send(channel, response); + }, + { channel, response }, + ); +}; + +const getStyleProperty = async (locator: Locator, property: string) => { + return locator.evaluate( + (el, { property }) => { + return window.getComputedStyle(el).getPropertyValue(property); + }, + { property }, + ); +}; + +export const getColor = async (locator: Locator) => { + return getStyleProperty(locator, 'color'); +}; + +export const getBackgroundColor = async (locator: Locator) => { + return getStyleProperty(locator, 'background-color'); +}; diff --git a/gui/test/account-data-cache.spec.ts b/gui/test/unit/account-data-cache.spec.ts index 6ed10c6908..68595b5586 100644 --- a/gui/test/account-data-cache.spec.ts +++ b/gui/test/unit/account-data-cache.spec.ts @@ -1,5 +1,5 @@ -import AccountDataCache from '../src/main/account-data-cache'; -import { IAccountData } from '../src/shared/daemon-rpc-types'; +import AccountDataCache from '../../src/main/account-data-cache'; +import { IAccountData } from '../../src/shared/daemon-rpc-types'; import sinon from 'sinon'; import { expect, spy } from 'chai'; diff --git a/gui/test/auth-failure.spec.ts b/gui/test/unit/auth-failure.spec.ts index 9f0072c499..61aa3bce61 100644 --- a/gui/test/auth-failure.spec.ts +++ b/gui/test/unit/auth-failure.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { it, describe } from 'mocha'; -import { parseAuthFailure, AuthFailureKind } from '../src/shared/auth-failure'; +import { parseAuthFailure, AuthFailureKind } from '../../src/shared/auth-failure'; describe('auth_failed parsing', () => { it('invalid line parsing works', () => { diff --git a/gui/test/date-helper.spec.ts b/gui/test/unit/date-helper.spec.ts index 5ae9c5d496..f86112a9a7 100644 --- a/gui/test/date-helper.spec.ts +++ b/gui/test/unit/date-helper.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { it, describe } from 'mocha'; -import * as date from '../src/shared/date-helper'; +import * as date from '../../src/shared/date-helper'; describe('Date helper', () => { it('should modify minutes', () => { diff --git a/gui/test/history.spec.ts b/gui/test/unit/history.spec.ts index 74f9ea2974..caec918724 100644 --- a/gui/test/history.spec.ts +++ b/gui/test/unit/history.spec.ts @@ -1,7 +1,7 @@ import { expect, spy } from 'chai'; import { it, describe, beforeEach } from 'mocha'; -import History from '../src/renderer/lib/history'; -import { RoutePath } from '../src/renderer/lib/routes'; +import History from '../../src/renderer/lib/history'; +import { RoutePath } from '../../src/renderer/lib/routes'; const BASE_PATH = RoutePath.launch; const FIRST_PATH = RoutePath.main; diff --git a/gui/test/ip.spec.ts b/gui/test/unit/ip.spec.ts index 3a86c5891c..10b93bc890 100644 --- a/gui/test/ip.spec.ts +++ b/gui/test/unit/ip.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { it, describe } from 'mocha'; -import * as ip from '../src/renderer/lib/ip'; +import * as ip from '../../src/renderer/lib/ip'; const validIpv4Addresses = [ '127.0.0.1', diff --git a/gui/test/keyframe-animation.spec.ts b/gui/test/unit/keyframe-animation.spec.ts index e3d6e417b0..59b5c2d6f7 100644 --- a/gui/test/keyframe-animation.spec.ts +++ b/gui/test/unit/keyframe-animation.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { it, describe } from 'mocha'; -import KeyframeAnimation from '../src/main/keyframe-animation'; +import KeyframeAnimation from '../../src/main/keyframe-animation'; describe('lib/keyframe-animation', function () { this.timeout(1000); diff --git a/gui/test/list-diff.spec.ts b/gui/test/unit/list-diff.spec.ts index bca3e72d84..9357579072 100644 --- a/gui/test/list-diff.spec.ts +++ b/gui/test/unit/list-diff.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { it, describe } from 'mocha'; -import { calculateItemList, RowDisplayData } from '../src/renderer/components/List'; +import { calculateItemList, RowDisplayData } from '../../src/renderer/components/List'; const prevItems: Array<RowDisplayData<undefined>> = [ { key: 'a', data: undefined, removing: false }, diff --git a/gui/test/logging.spec.ts b/gui/test/unit/logging.spec.ts index 92f1a92e1f..d107a23d3b 100644 --- a/gui/test/logging.spec.ts +++ b/gui/test/unit/logging.spec.ts @@ -3,9 +3,9 @@ import fs from 'fs'; import sinon from 'sinon'; import { it, describe, before, beforeEach, after } from 'mocha'; import path from 'path'; -import { Logger } from '../src/shared/logging'; -import { backupLogFile, rotateOrDeleteFile } from '../src/main/logging'; -import { LogLevel } from '../src/shared/logging-types'; +import { Logger } from '../../src/shared/logging'; +import { backupLogFile, rotateOrDeleteFile } from '../../src/main/logging'; +import { LogLevel } from '../../src/shared/logging-types'; const aPath = path.normalize('log-directory/a.log'); const oldAPath = path.normalize('log-directory/a.old.log'); diff --git a/gui/test/relay-settings-builder.spec.ts b/gui/test/unit/relay-settings-builder.spec.ts index f669d43d2b..87a3fd5dae 100644 --- a/gui/test/relay-settings-builder.spec.ts +++ b/gui/test/unit/relay-settings-builder.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { it, describe } from 'mocha'; -import RelaySettingsBuilder from '../src/shared/relay-settings-builder'; +import RelaySettingsBuilder from '../../src/shared/relay-settings-builder'; describe('Relay settings builder', () => { it('should set location to any', () => { diff --git a/gui/test/setup/changelog.spec.ts b/gui/test/unit/setup/changelog.spec.ts index 249c26e1db..912a8a3397 100644 --- a/gui/test/setup/changelog.spec.ts +++ b/gui/test/unit/setup/changelog.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { after, it, describe } from 'mocha'; -import { parseChangelog } from '../../src/main/changelog'; +import { parseChangelog } from '../../../src/main/changelog'; // It should be handled the same no matter if the platforms are split with a space or not. const changelogItems = [ diff --git a/gui/test/setup/renderer.ts b/gui/test/unit/setup/renderer.ts index 65644e28d9..65644e28d9 100644 --- a/gui/test/setup/renderer.ts +++ b/gui/test/unit/setup/renderer.ts diff --git a/gui/test/tunnel-state.spec.ts b/gui/test/unit/tunnel-state.spec.ts index 5720acda1d..8db57aea73 100644 --- a/gui/test/tunnel-state.spec.ts +++ b/gui/test/unit/tunnel-state.spec.ts @@ -1,8 +1,8 @@ import { expect, spy } from 'chai'; import { it, describe } from 'mocha'; import sinon from 'sinon'; -import TunnelStateHandler from '../src/main/tunnel-state'; -import { TunnelState } from '../src/shared/daemon-rpc-types'; +import TunnelStateHandler from '../../src/main/tunnel-state'; +import { TunnelState } from '../../src/shared/daemon-rpc-types'; const connected: TunnelState = { state: 'connected' } as TunnelState; const connecting: TunnelState = { state: 'connecting' } as TunnelState; diff --git a/gui/tsconfig.json b/gui/tsconfig.json index 6dc201bcce..8887dcbfe6 100644 --- a/gui/tsconfig.json +++ b/gui/tsconfig.json @@ -29,6 +29,9 @@ "include": [ "src/**/*.ts", "src/**/*.tsx", + "playwright.config.ts", + "scripts/**/*.ts", + "test/**/*.ts", "assets/geo/*.json" ] } diff --git a/gui/types/global/index.d.ts b/gui/types/global/index.d.ts index fe60baf4ca..72210cacc7 100644 --- a/gui/types/global/index.d.ts +++ b/gui/types/global/index.d.ts @@ -3,6 +3,6 @@ import { IpcRendererEventChannel } from '../../src/renderer/lib/ipc-event-channe declare global { interface Window { ipc: typeof IpcRendererEventChannel; - env: { platform: NodeJS.Platform; development: boolean }; + env: { platform: NodeJS.Platform; development: boolean; e2e: boolean }; } } |
