summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorHank <hank@mullvad.net>2022-09-20 11:37:37 +0200
committerHank <hank@mullvad.net>2022-09-20 11:37:37 +0200
commitc7b7e13059c1d839d41d2dd8728235111ad648dd (patch)
tree436acdc89b109c442eb047b6bcd97e294731c0c8
parent5e04ba5fdf766747a993a2182e5f3ed6298b4b1a (diff)
parent162d1e392306c283c7d06def2884471a5e19fca4 (diff)
downloadmullvadvpn-c7b7e13059c1d839d41d2dd8728235111ad648dd.tar.xz
mullvadvpn-c7b7e13059c1d839d41d2dd8728235111ad648dd.zip
Merge branch 'e2e'
-rw-r--r--.github/workflows/frontend.yml3
-rw-r--r--.gitignore3
-rw-r--r--gui/.eslintignore4
-rw-r--r--gui/.eslintrc.js1
-rw-r--r--gui/package-lock.json97
-rw-r--r--gui/package.json11
-rw-r--r--gui/playwright.config.ts18
-rw-r--r--gui/src/main/relay-list.ts8
-rw-r--r--gui/src/renderer/.eslintignore1
-rw-r--r--gui/src/renderer/preload.ts1
-rw-r--r--gui/src/renderer/redux/store.ts17
-rw-r--r--gui/tasks/distribution.js2
-rw-r--r--gui/test/e2e/main.spec.ts21
-rw-r--r--gui/test/e2e/settings.spec.ts24
-rw-r--r--gui/test/e2e/setup/main.ts195
-rw-r--r--gui/test/e2e/tunnel-state.spec.ts179
-rw-r--r--gui/test/e2e/utils.ts80
-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.json3
-rw-r--r--gui/types/global/index.d.ts2
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 };
}
}