summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar <oskar@mullvad.net>2025-10-01 13:19:55 +0200
committerOskar <oskar@mullvad.net>2025-10-01 13:19:55 +0200
commit411165eaa0a8f278797f68ef9063ff3ccfd17385 (patch)
treebdb2d40258075e88d0ec3a713c9088d119268262
parente6e729ec9c31d09c2d32e0f118bc4cfcc3d7b1f9 (diff)
parent60339c6f48f932300ea0c28a290300692899272c (diff)
downloadmullvadvpn-411165eaa0a8f278797f68ef9063ff3ccfd17385.tar.xz
mullvadvpn-411165eaa0a8f278797f68ef9063ff3ccfd17385.zip
Merge branch 'out-of-time-view-persists-after-adding-time-to-account-des-2339'
-rw-r--r--desktop/package-lock.json62
-rw-r--r--desktop/packages/mullvad-vpn/package.json4
-rw-r--r--desktop/packages/mullvad-vpn/src/main/account.ts42
-rw-r--r--desktop/packages/mullvad-vpn/src/main/system-time-monitor.ts15
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/app.tsx151
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx82
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/KeyboardNavigation.tsx9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/StateTriggeredNavigation.tsx106
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/functions/navigation-base.ts29
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/routeHelpers.ts12
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/transition-hooks.ts29
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/account/reducers.ts9
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/tunnel-state.spec.ts12
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/lib/path-helpers.ts19
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/account-expiry.spec.ts170
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/device-revoked.spec.ts64
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators/feature-indicators.spec.ts3
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/launch.spec.ts8
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts11
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/too-many-devices.spec.ts68
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/device-revoked/device-revoked-route-object-model.ts10
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/device-revoked/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/expired/expired-route-object-model.ts25
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/expired/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/expired/selectors.ts5
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/navigation/navigation-object-model.ts14
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/redeem-voucher/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/redeem-voucher/redeem-voucher-route-object-model.ts28
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/redeem-voucher/selectors.ts6
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/routes-object-model.ts21
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/setup-finished/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/setup-finished/selectors.ts5
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/setup-finished/setup-finished-route-object-model.ts25
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/shadowsocks-settings/selectors.ts4
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/time-added/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/time-added/selectors.ts5
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/time-added/time-added-route-object-model.ts24
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/too-many-devices/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/too-many-devices/selectors.ts5
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/too-many-devices/too-many-devices-route-object-model.ts24
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/voucher-success/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/voucher-success/selectors.ts5
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/voucher-success/voucher-success-route-object-model.ts27
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/utils.ts8
-rw-r--r--desktop/packages/mullvad-vpn/test/unit/path-helpers.spec.ts13
-rw-r--r--desktop/packages/mullvad-vpn/test/unit/system-time-monitor.spec.ts29
46 files changed, 917 insertions, 284 deletions
diff --git a/desktop/package-lock.json b/desktop/package-lock.json
index 13aea457b7..cff37c4c10 100644
--- a/desktop/package-lock.json
+++ b/desktop/package-lock.json
@@ -2305,18 +2305,19 @@
}
},
"node_modules/@playwright/test": {
- "version": "1.41.1",
- "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.1.tgz",
- "integrity": "sha512-9g8EWTjiQ9yFBXc6HjCWe41msLpxEX0KhmfmPl9RPLJdfzL4F0lg2BdJ91O9azFdl11y1pmpwdjBiSxvqc+btw==",
+ "version": "1.55.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
+ "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
"dev": true,
+ "license": "Apache-2.0",
"dependencies": {
- "playwright": "1.41.1"
+ "playwright": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
- "node": ">=16"
+ "node": ">=18"
}
},
"node_modules/@protobufjs/aspromise": {
@@ -8692,33 +8693,35 @@
}
},
"node_modules/playwright": {
- "version": "1.41.1",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.1.tgz",
- "integrity": "sha512-gdZAWG97oUnbBdRL3GuBvX3nDDmUOuqzV/D24dytqlKt+eI5KbwusluZRGljx1YoJKZ2NRPaeWiFTeGZO7SosQ==",
+ "version": "1.55.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
+ "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
"dev": true,
+ "license": "Apache-2.0",
"dependencies": {
- "playwright-core": "1.41.1"
+ "playwright-core": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
- "node": ">=16"
+ "node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
- "version": "1.41.1",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.1.tgz",
- "integrity": "sha512-/KPO5DzXSMlxSX77wy+HihKGOunh3hqndhqeo/nMxfigiKzogn8kfL0ZBDu0L1RKgan5XHCPmn6zXd2NUJgjhg==",
+ "version": "1.55.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
+ "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
"dev": true,
+ "license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
- "node": ">=16"
+ "node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
@@ -8727,6 +8730,7 @@
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
+ "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -11333,7 +11337,7 @@
"windows-utils": "0.0.0"
},
"devDependencies": {
- "@playwright/test": "^1.41.1",
+ "@playwright/test": "^1.55.0",
"@types/chai": "^4.3.3",
"@types/chai-as-promised": "^7.1.5",
"@types/chai-spies": "^1.0.3",
@@ -11362,7 +11366,7 @@
"gettext-extractor": "^3.5.4",
"globals": "^15.9.0",
"mocha": "^10.8.2",
- "playwright": "^1.41.1",
+ "playwright": "^1.55.0",
"postject": "^1.0.0-alpha.6",
"sinon": "^14.0.1",
"vite": "7.1.7",
@@ -13259,12 +13263,12 @@
"dev": true
},
"@playwright/test": {
- "version": "1.41.1",
- "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.1.tgz",
- "integrity": "sha512-9g8EWTjiQ9yFBXc6HjCWe41msLpxEX0KhmfmPl9RPLJdfzL4F0lg2BdJ91O9azFdl11y1pmpwdjBiSxvqc+btw==",
+ "version": "1.55.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
+ "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
"dev": true,
"requires": {
- "playwright": "1.41.1"
+ "playwright": "1.55.0"
}
},
"@protobufjs/aspromise": {
@@ -17472,7 +17476,7 @@
"version": "file:packages/mullvad-vpn",
"requires": {
"@grpc/grpc-js": "^1.12.2",
- "@playwright/test": "^1.41.1",
+ "@playwright/test": "^1.55.0",
"@rollup/rollup-darwin-arm64": "4.34.6",
"@rollup/rollup-darwin-x64": "4.34.6",
"@rollup/rollup-linux-arm64-gnu": "4.34.6",
@@ -17514,7 +17518,7 @@
"mocha": "^10.8.2",
"node-gettext": "^3.0.0",
"nseventforwarder": "0.0.0",
- "playwright": "^1.41.1",
+ "playwright": "^1.55.0",
"postject": "^1.0.0-alpha.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
@@ -18162,13 +18166,13 @@
"dev": true
},
"playwright": {
- "version": "1.41.1",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.1.tgz",
- "integrity": "sha512-gdZAWG97oUnbBdRL3GuBvX3nDDmUOuqzV/D24dytqlKt+eI5KbwusluZRGljx1YoJKZ2NRPaeWiFTeGZO7SosQ==",
+ "version": "1.55.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
+ "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
"dev": true,
"requires": {
"fsevents": "2.3.2",
- "playwright-core": "1.41.1"
+ "playwright-core": "1.55.0"
},
"dependencies": {
"fsevents": {
@@ -18181,9 +18185,9 @@
}
},
"playwright-core": {
- "version": "1.41.1",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.1.tgz",
- "integrity": "sha512-/KPO5DzXSMlxSX77wy+HihKGOunh3hqndhqeo/nMxfigiKzogn8kfL0ZBDu0L1RKgan5XHCPmn6zXd2NUJgjhg==",
+ "version": "1.55.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
+ "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
"dev": true
},
"plist": {
diff --git a/desktop/packages/mullvad-vpn/package.json b/desktop/packages/mullvad-vpn/package.json
index 4be70cc626..f7c8a83d93 100644
--- a/desktop/packages/mullvad-vpn/package.json
+++ b/desktop/packages/mullvad-vpn/package.json
@@ -31,7 +31,7 @@
"windows-utils": "0.0.0"
},
"devDependencies": {
- "@playwright/test": "^1.41.1",
+ "@playwright/test": "^1.55.0",
"@types/chai": "^4.3.3",
"@types/chai-as-promised": "^7.1.5",
"@types/chai-spies": "^1.0.3",
@@ -60,7 +60,7 @@
"gettext-extractor": "^3.5.4",
"globals": "^15.9.0",
"mocha": "^10.8.2",
- "playwright": "^1.41.1",
+ "playwright": "^1.55.0",
"postject": "^1.0.0-alpha.6",
"sinon": "^14.0.1",
"vite": "7.1.7",
diff --git a/desktop/packages/mullvad-vpn/src/main/account.ts b/desktop/packages/mullvad-vpn/src/main/account.ts
index 7fc6b48d47..7ee415c0b4 100644
--- a/desktop/packages/mullvad-vpn/src/main/account.ts
+++ b/desktop/packages/mullvad-vpn/src/main/account.ts
@@ -1,4 +1,4 @@
-import { closeToExpiry } from '../shared/account-expiry';
+import { closeToExpiry, hasExpired } from '../shared/account-expiry';
import {
AccountDataError,
AccountNumber,
@@ -19,6 +19,7 @@ import AccountDataCache from './account-data-cache';
import { DaemonRpc } from './daemon-rpc';
import { IpcMainEventChannel } from './ipc-event-channel';
import { NotificationSender } from './notification-controller';
+import { systemTimeMonitor } from './system-time-monitor';
import { TunnelStateProvider } from './tunnel-state';
export interface LocaleProvider {
@@ -35,16 +36,14 @@ export default class Account {
private expiryNotificationFrequencyScheduler = new Scheduler();
private firstExpiryNotificationScheduler = new Scheduler();
+ private hasExpired = false;
+
private accountDataCache = new AccountDataCache(
(accountNumber) => {
return this.daemonRpc.getAccountData(accountNumber);
},
(accountData) => {
- this.accountDataValue = accountData;
-
- IpcMainEventChannel.account.notify?.(this.accountData);
-
- this.handleAccountExpiry();
+ this.handleAccountData(accountData);
},
);
@@ -53,7 +52,9 @@ export default class Account {
public constructor(
private delegate: AccountDelegate & TunnelStateProvider & LocaleProvider & NotificationSender,
private daemonRpc: DaemonRpc,
- ) {}
+ ) {
+ this.monitorExpiredChange();
+ }
public get accountData() {
return this.accountDataValue;
@@ -110,10 +111,10 @@ export default class Account {
};
public detectStaleAccountExpiry(tunnelState: TunnelState) {
- const hasExpired = !this.accountData || new Date() >= new Date(this.accountData.expiry);
+ const expired = !this.accountData || hasExpired(this.accountData.expiry);
// It's likely that the account expiry is stale if the daemon managed to establish the tunnel.
- if (tunnelState.state === 'connected' && hasExpired) {
+ if (tunnelState.state === 'connected' && expired) {
log.info('Detected the stale account expiry.');
this.accountDataCache.invalidate();
}
@@ -146,6 +147,16 @@ export default class Account {
IpcMainEventChannel.accountHistory.notify?.(accountHistory);
}
+ // This function monitors if the account is expired due to system clock changes.
+ private monitorExpiredChange() {
+ systemTimeMonitor(() => {
+ const expired = this.accountData && hasExpired(this.accountData.expiry);
+ if (expired !== this.hasExpired) {
+ this.handleAccountData(this.accountData);
+ }
+ });
+ }
+
private async createNewAccount(): Promise<string> {
try {
return await this.daemonRpc.createNewAccount();
@@ -180,7 +191,14 @@ export default class Account {
}
}
- private handleAccountExpiry() {
+ private handleAccountData(accountData?: IAccountData) {
+ this.accountDataValue = accountData;
+ this.hasExpired = this.accountData !== undefined && hasExpired(this.accountData?.expiry);
+ IpcMainEventChannel.account.notify?.(this.accountData);
+ this.showNotifications();
+ }
+
+ private showNotifications() {
if (this.accountData) {
const expiredNotification = new AccountExpiredNotificationProvider({
accountExpiry: this.accountData.expiry,
@@ -205,7 +223,7 @@ export default class Account {
const twelveHours = 12 * 60 * 60 * 1000;
const remainingMilliseconds = new Date(this.accountData.expiry).getTime() - Date.now();
const delay = Math.min(twelveHours, remainingMilliseconds);
- this.expiryNotificationFrequencyScheduler.schedule(() => this.handleAccountExpiry(), delay);
+ this.expiryNotificationFrequencyScheduler.schedule(() => this.showNotifications(), delay);
} else if (!closeToExpiry(this.accountData.expiry)) {
this.expiryNotificationFrequencyScheduler.cancel();
// If no longer close to expiry, all previous notifications should be closed
@@ -217,7 +235,7 @@ export default class Account {
// Add 10 seconds to be on the safe side. Never make it longer than a 24 days since
// the timeout needs to fit into a signed 32-bit integer.
const timeout = Math.min(expiry - now - threeDays + 10_000, 24 * 24 * 60 * 60 * 1000);
- this.firstExpiryNotificationScheduler.schedule(() => this.handleAccountExpiry(), timeout);
+ this.firstExpiryNotificationScheduler.schedule(() => this.showNotifications(), timeout);
}
}
}
diff --git a/desktop/packages/mullvad-vpn/src/main/system-time-monitor.ts b/desktop/packages/mullvad-vpn/src/main/system-time-monitor.ts
new file mode 100644
index 0000000000..308c38e527
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/main/system-time-monitor.ts
@@ -0,0 +1,15 @@
+const INTERVAL = 1000;
+
+// This functions monitors the system clock for changes, such as NTP corrections or user manually
+// changing the time. It probably has a lot of false positives, e.g. after suspend. And it only
+// checks once a second so the event will be a bit delayed.
+export function systemTimeMonitor(listener: () => void) {
+ let prevDate = Date.now();
+ setInterval(() => {
+ const now = Date.now();
+ if (Math.abs(now - prevDate - INTERVAL) > 500) {
+ listener();
+ }
+ prevDate = now;
+ }, INTERVAL);
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/app.tsx b/desktop/packages/mullvad-vpn/src/renderer/app.tsx
index 5b7f625019..85e6d80687 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/app.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/app.tsx
@@ -17,7 +17,6 @@ import {
BridgeState,
CustomProxy,
DeviceEvent,
- DeviceState,
IAccountData,
IAppVersionInfo,
ICustomList,
@@ -54,7 +53,8 @@ import MacOsScrollbarDetection from './components/MacOsScrollbarDetection';
import { ModalContainer } from './components/Modal';
import { AppContext } from './context';
import { Theme } from './lib/components';
-import History, { TransitionType } from './lib/history';
+import { getNavigationBase } from './lib/functions/navigation-base';
+import History from './lib/history';
import { loadTranslations } from './lib/load-translations';
import IpcOutput from './lib/logging';
import accountActions from './redux/account/actions';
@@ -113,9 +113,7 @@ export default class AppRenderer {
private relayList?: IRelayListWithEndpointData;
private tunnelState!: TunnelState;
private settings!: ISettings;
- private deviceState?: DeviceState;
private loginState: LoginState = 'none';
- private previousLoginState: LoginState = 'none';
private connectedToDaemon = false;
private loginScheduler = new Scheduler();
@@ -282,10 +280,7 @@ export default class AppRenderer {
if (initialState.deviceState) {
const deviceState = initialState.deviceState;
- this.handleDeviceEvent(
- { type: deviceState.type, deviceState } as DeviceEvent,
- initialState.navigationHistory !== undefined,
- );
+ this.handleDeviceEvent({ type: deviceState.type, deviceState } as DeviceEvent);
}
// Login state and account needs to be set before expiry.
this.setAccountExpiry(initialState.accountData?.expiry);
@@ -331,7 +326,8 @@ export default class AppRenderer {
initialState.navigationHistory.lastAction = 'POP';
this.history = History.fromSavedHistory(initialState.navigationHistory);
} else {
- const navigationBase = this.getNavigationBase();
+ const loginState = this.reduxStore.getState().account.status;
+ const navigationBase = getNavigationBase(this.connectedToDaemon, loginState);
this.history = new History(navigationBase);
}
@@ -493,7 +489,6 @@ export default class AppRenderer {
log.info('Logging in');
- this.previousLoginState = this.loginState;
this.loginState = 'logging in';
const response = await IpcRendererEventChannel.account.login(accountNumber);
@@ -504,8 +499,6 @@ export default class AppRenderer {
actions.account.loginTooManyDevices();
this.loginState = 'too many devices';
-
- this.history.reset(RoutePath.tooManyDevices, { transition: TransitionType.push });
} catch {
log.error('Failed to fetch device list');
actions.account.loginFailed('list-devices');
@@ -522,9 +515,8 @@ export default class AppRenderer {
this.loginState = 'none';
};
- public logout = async (transition = TransitionType.dismiss) => {
+ public logout = async () => {
try {
- this.history.reset(RoutePath.login, { transition });
await IpcRendererEventChannel.account.logout();
} catch (e) {
const error = e as Error;
@@ -533,7 +525,7 @@ export default class AppRenderer {
};
public leaveRevokedDevice = async () => {
- await this.logout(TransitionType.pop);
+ await this.logout();
await this.disconnectTunnel();
};
@@ -546,7 +538,6 @@ export default class AppRenderer {
try {
await IpcRendererEventChannel.account.create();
- this.redirectToConnect();
} catch (e) {
const error = e as Error;
actions.account.createAccountFailed(error);
@@ -727,10 +718,6 @@ export default class AppRenderer {
}
}
- private isLoggedIn(): boolean {
- return this.deviceState?.type === 'logged in';
- }
-
// Make sure that the content height is correct and log if it isn't. This is mostly for debugging
// purposes since there's a bug in Electron that causes the app height to be another value than
// the one we have set.
@@ -746,11 +733,6 @@ export default class AppRenderer {
}
}
- private redirectToConnect() {
- // Redirect the user after some time to allow for the 'Logged in' screen to be visible
- this.loginScheduler.schedule(() => this.resetNavigation(), 1000);
- }
-
private setLocale(locale: string) {
this.reduxActions.userInterface.updateLocale(locale);
}
@@ -823,93 +805,12 @@ export default class AppRenderer {
this.reduxActions.userInterface.setConnectedToDaemon(true);
this.reduxActions.userInterface.setDaemonAllowed(true);
this.reduxActions.userInterface.setDaemonStatus('running');
- this.resetNavigation();
}
private onDaemonDisconnected() {
this.connectedToDaemon = false;
this.reduxActions.userInterface.setConnectedToDaemon(false);
this.reduxActions.userInterface.setDaemonStatus('stopped');
- this.resetNavigation();
- }
-
- private resetNavigation(replaceRoot?: boolean) {
- if (this.history) {
- const pathname = this.history.location.pathname as RoutePath;
- const nextPath = this.getNavigationBase() as RoutePath;
-
- if (pathname !== nextPath) {
- const transition = this.getNavigationTransition(pathname, nextPath);
- if (replaceRoot) {
- this.history.replaceRoot(nextPath, { transition });
- } else {
- this.history.reset(nextPath, { transition });
- }
- }
- }
- }
-
- private getNavigationTransition(prevPath: RoutePath, nextPath: RoutePath) {
- // First level contains the possible next locations and the second level contains the
- // possible current locations.
- const navigationTransitions: Partial<
- Record<RoutePath, Partial<Record<RoutePath | '*', TransitionType>>>
- > = {
- [RoutePath.launch]: {
- [RoutePath.login]: TransitionType.pop,
- [RoutePath.main]: TransitionType.pop,
- '*': TransitionType.dismiss,
- },
- [RoutePath.login]: {
- [RoutePath.launch]: TransitionType.push,
- [RoutePath.main]: TransitionType.pop,
- [RoutePath.deviceRevoked]: TransitionType.pop,
- '*': TransitionType.dismiss,
- },
- [RoutePath.main]: {
- [RoutePath.launch]: TransitionType.push,
- [RoutePath.login]: TransitionType.push,
- [RoutePath.tooManyDevices]: TransitionType.push,
- '*': TransitionType.dismiss,
- },
- [RoutePath.expired]: {
- [RoutePath.launch]: TransitionType.push,
- [RoutePath.login]: TransitionType.push,
- [RoutePath.tooManyDevices]: TransitionType.push,
- '*': TransitionType.dismiss,
- },
- [RoutePath.timeAdded]: {
- [RoutePath.expired]: TransitionType.push,
- [RoutePath.redeemVoucher]: TransitionType.push,
- '*': TransitionType.dismiss,
- },
- [RoutePath.deviceRevoked]: {
- '*': TransitionType.pop,
- },
- };
-
- return navigationTransitions[nextPath]?.[prevPath] ?? navigationTransitions[nextPath]?.['*'];
- }
-
- private getNavigationBase(): RoutePath {
- if (this.connectedToDaemon && this.deviceState !== undefined) {
- const loginState = this.reduxStore.getState().account.status;
- const deviceRevoked = loginState.type === 'none' && loginState.deviceRevoked;
-
- if (deviceRevoked) {
- return RoutePath.deviceRevoked;
- } else if (!this.isLoggedIn()) {
- return RoutePath.login;
- } else if (loginState.type === 'ok' && loginState.expiredState === 'expired') {
- return RoutePath.expired;
- } else if (loginState.type === 'ok' && loginState.expiredState === 'time_added') {
- return RoutePath.timeAdded;
- } else {
- return RoutePath.main;
- }
- } else {
- return RoutePath.launch;
- }
}
private setAccountHistory(accountHistory?: AccountNumber) {
@@ -1005,11 +906,9 @@ export default class AppRenderer {
}
}
- private handleDeviceEvent(deviceEvent: DeviceEvent, preventRedirectToConnect?: boolean) {
+ private handleDeviceEvent(deviceEvent: DeviceEvent) {
const reduxAccount = this.reduxActions.account;
- this.deviceState = deviceEvent.deviceState;
-
switch (deviceEvent.type) {
case 'logged in': {
const accountNumber = deviceEvent.deviceState.accountAndDevice.accountNumber;
@@ -1018,16 +917,9 @@ export default class AppRenderer {
switch (this.loginState) {
case 'none':
reduxAccount.loggedIn(accountNumber, device);
- this.resetNavigation();
break;
case 'logging in':
reduxAccount.loggedIn(accountNumber, device);
-
- if (this.previousLoginState === 'too many devices') {
- this.resetNavigation();
- } else if (!preventRedirectToConnect) {
- this.redirectToConnect();
- }
break;
case 'creating account':
reduxAccount.accountCreated(accountNumber, device, new Date().toISOString());
@@ -1038,17 +930,14 @@ export default class AppRenderer {
case 'logged out':
this.loginScheduler.cancel();
reduxAccount.loggedOut();
- this.resetNavigation();
break;
case 'revoked': {
this.loginScheduler.cancel();
reduxAccount.deviceRevoked();
- this.resetNavigation();
break;
}
}
- this.previousLoginState = this.loginState;
this.loginState = 'none';
}
@@ -1092,9 +981,7 @@ export default class AppRenderer {
}
private setAccountExpiry(expiry?: string) {
- const state = this.reduxStore.getState();
- const previousExpiry = state.account.expiry;
-
+ log.info(`setAccountExpiry(${expiry}) called at ${new Date().toISOString()}`);
this.expiryScheduler.cancel();
if (expiry !== undefined) {
@@ -1103,31 +990,17 @@ export default class AppRenderer {
// Set state to expired when expiry date passes.
if (!expired && closeToExpiry(expiry)) {
const delay = new Date(expiry).getTime() - Date.now() + 1;
- this.expiryScheduler.schedule(() => this.handleExpiry(expiry, true), delay);
+ this.expiryScheduler.schedule(() => this.handleExpiry(expiry), delay);
}
- if (expiry !== previousExpiry) {
- this.handleExpiry(expiry, expired);
- }
+ this.handleExpiry(expiry);
} else {
this.handleExpiry(expiry);
}
}
- private handleExpiry(expiry?: string, expired?: boolean) {
- const state = this.reduxStore.getState();
+ private handleExpiry(expiry?: string) {
this.reduxActions.account.updateAccountExpiry(expiry);
-
- if (
- expiry !== undefined &&
- state.account.status.type === 'ok' &&
- ((state.account.status.expiredState === undefined && expired) ||
- (state.account.status.expiredState === 'expired' && !expired)) &&
- // If the login navigation is already scheduled no navigation is needed
- !this.loginScheduler.isRunning
- ) {
- this.resetNavigation(true);
- }
}
private storeAutoStart(autoStart: boolean) {
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx
index 5f5f2c4082..5f0bf016d2 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx
@@ -23,6 +23,7 @@ import ProblemReport from './ProblemReport';
import SelectLanguage from './SelectLanguage';
import SettingsImport from './SettingsImport';
import SettingsTextImport from './SettingsTextImport';
+import StateTriggeredNavigation from './StateTriggeredNavigation';
import Support from './Support';
import TooManyDevices from './TooManyDevices';
import UserInterfaceSettings from './UserInterfaceSettings';
@@ -53,44 +54,47 @@ export default function AppRouter() {
const currentLocation = useViewTransitions(onNavigation);
return (
- <Focus ref={focusRef}>
- <Switch key={currentLocation.key} location={currentLocation}>
- <Route exact path={RoutePath.launch} component={LaunchView} />
- <Route exact path={RoutePath.login} component={LoginView} />
- <Route exact path={RoutePath.tooManyDevices} component={TooManyDevices} />
- <Route exact path={RoutePath.deviceRevoked} component={DeviceRevokedView} />
- <Route exact path={RoutePath.main} component={MainView} />
- <Route exact path={RoutePath.expired} component={ExpiredAccountErrorView} />
- <Route exact path={RoutePath.redeemVoucher} component={VoucherInput} />
- <Route exact path={RoutePath.voucherSuccess} component={VoucherVerificationSuccess} />
- <Route exact path={RoutePath.timeAdded} component={TimeAdded} />
- <Route exact path={RoutePath.setupFinished} component={SetupFinished} />
- <Route exact path={RoutePath.account} component={Account} />
- <Route exact path={RoutePath.settings} component={SettingsView} />
- <Route exact path={RoutePath.selectLanguage} component={SelectLanguage} />
- <Route exact path={RoutePath.userInterfaceSettings} component={UserInterfaceSettings} />
- <Route exact path={RoutePath.multihopSettings} component={MultihopSettingsView} />
- <Route exact path={RoutePath.vpnSettings} component={VpnSettingsView} />
- <Route exact path={RoutePath.wireguardSettings} component={WireguardSettingsView} />
- <Route exact path={RoutePath.daitaSettings} component={DaitaSettingsView} />
- <Route exact path={RoutePath.udpOverTcp} component={UdpOverTcpSettingsView} />
- <Route exact path={RoutePath.shadowsocks} component={ShadowsocksSettingsView} />
- <Route exact path={RoutePath.openVpnSettings} component={OpenVpnSettingsView} />
- <Route exact path={RoutePath.splitTunneling} component={SplitTunnelingView} />
- <Route exact path={RoutePath.apiAccessMethods} component={ApiAccessMethods} />
- <Route exact path={RoutePath.settingsImport} component={SettingsImport} />
- <Route exact path={RoutePath.settingsTextImport} component={SettingsTextImport} />
- <Route exact path={RoutePath.editApiAccessMethods} component={EditApiAccessMethod} />
- <Route exact path={RoutePath.support} component={Support} />
- <Route exact path={RoutePath.problemReport} component={ProblemReport} />
- <Route exact path={RoutePath.debug} component={Debug} />
- <Route exact path={RoutePath.selectLocation} component={SelectLocation} />
- <Route exact path={RoutePath.editCustomBridge} component={EditCustomBridge} />
- <Route exact path={RoutePath.filter} component={Filter} />
- <Route exact path={RoutePath.appInfo} component={AppInfoView} />
- <Route exact path={RoutePath.changelog} component={ChangelogView} />
- <Route exact path={RoutePath.appUpgrade} component={AppUpgradeView} />
- </Switch>
- </Focus>
+ <>
+ <StateTriggeredNavigation />
+ <Focus ref={focusRef}>
+ <Switch key={currentLocation.key} location={currentLocation}>
+ <Route exact path={RoutePath.launch} component={LaunchView} />
+ <Route exact path={RoutePath.login} component={LoginView} />
+ <Route exact path={RoutePath.tooManyDevices} component={TooManyDevices} />
+ <Route exact path={RoutePath.deviceRevoked} component={DeviceRevokedView} />
+ <Route exact path={RoutePath.main} component={MainView} />
+ <Route exact path={RoutePath.expired} component={ExpiredAccountErrorView} />
+ <Route exact path={RoutePath.redeemVoucher} component={VoucherInput} />
+ <Route exact path={RoutePath.voucherSuccess} component={VoucherVerificationSuccess} />
+ <Route exact path={RoutePath.timeAdded} component={TimeAdded} />
+ <Route exact path={RoutePath.setupFinished} component={SetupFinished} />
+ <Route exact path={RoutePath.account} component={Account} />
+ <Route exact path={RoutePath.settings} component={SettingsView} />
+ <Route exact path={RoutePath.selectLanguage} component={SelectLanguage} />
+ <Route exact path={RoutePath.userInterfaceSettings} component={UserInterfaceSettings} />
+ <Route exact path={RoutePath.multihopSettings} component={MultihopSettingsView} />
+ <Route exact path={RoutePath.vpnSettings} component={VpnSettingsView} />
+ <Route exact path={RoutePath.wireguardSettings} component={WireguardSettingsView} />
+ <Route exact path={RoutePath.daitaSettings} component={DaitaSettingsView} />
+ <Route exact path={RoutePath.udpOverTcp} component={UdpOverTcpSettingsView} />
+ <Route exact path={RoutePath.shadowsocks} component={ShadowsocksSettingsView} />
+ <Route exact path={RoutePath.openVpnSettings} component={OpenVpnSettingsView} />
+ <Route exact path={RoutePath.splitTunneling} component={SplitTunnelingView} />
+ <Route exact path={RoutePath.apiAccessMethods} component={ApiAccessMethods} />
+ <Route exact path={RoutePath.settingsImport} component={SettingsImport} />
+ <Route exact path={RoutePath.settingsTextImport} component={SettingsTextImport} />
+ <Route exact path={RoutePath.editApiAccessMethods} component={EditApiAccessMethod} />
+ <Route exact path={RoutePath.support} component={Support} />
+ <Route exact path={RoutePath.problemReport} component={ProblemReport} />
+ <Route exact path={RoutePath.debug} component={Debug} />
+ <Route exact path={RoutePath.selectLocation} component={SelectLocation} />
+ <Route exact path={RoutePath.editCustomBridge} component={EditCustomBridge} />
+ <Route exact path={RoutePath.filter} component={Filter} />
+ <Route exact path={RoutePath.appInfo} component={AppInfoView} />
+ <Route exact path={RoutePath.changelog} component={ChangelogView} />
+ <Route exact path={RoutePath.appUpgrade} component={AppUpgradeView} />
+ </Switch>
+ </Focus>
+ </>
);
}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/KeyboardNavigation.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/KeyboardNavigation.tsx
index 79fde5e536..762ef46fc7 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/KeyboardNavigation.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/KeyboardNavigation.tsx
@@ -1,9 +1,6 @@
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
-import { useLocation } from 'react-router';
-import { RoutePath } from '../../shared/routes';
import { useHistory } from '../lib/history';
-import { disableDismissForRoutes } from '../lib/routeHelpers';
import { useEffectEvent } from '../lib/utility-hooks';
interface IKeyboardNavigationProps {
@@ -14,7 +11,6 @@ interface IKeyboardNavigationProps {
export default function KeyboardNavigation(props: IKeyboardNavigationProps) {
const { pop } = useHistory();
const [backAction, setBackActionImpl] = useState<BackActionFn>();
- const location = useLocation();
// Since the backaction is now a function we need to make sure it's not called when setting the
// state.
@@ -25,15 +21,14 @@ export default function KeyboardNavigation(props: IKeyboardNavigationProps) {
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') {
- const path = location.pathname as RoutePath;
- if (event.shiftKey && !disableDismissForRoutes.includes(path)) {
+ if (event.shiftKey && window.env.development) {
pop(true);
} else {
backAction?.();
}
}
},
- [pop, backAction, location.pathname],
+ [pop, backAction],
);
useEffect(() => {
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/StateTriggeredNavigation.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/StateTriggeredNavigation.tsx
new file mode 100644
index 0000000000..3a275e2f61
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/StateTriggeredNavigation.tsx
@@ -0,0 +1,106 @@
+import { useEffect, useMemo } from 'react';
+
+import { RoutePath } from '../../shared/routes';
+import { useScheduler } from '../../shared/scheduler';
+import { getNavigationBase } from '../lib/functions/navigation-base';
+import { TransitionType, useHistory } from '../lib/history';
+import { useEffectEvent } from '../lib/utility-hooks';
+import { useSelector } from '../redux/store';
+
+export default function StateTriggeredNavigation() {
+ const { location, reset } = useHistory();
+
+ const connectedToDaemon = useSelector((state) => state.userInterface.connectedToDaemon);
+ const loginState = useSelector((state) => state.account.status);
+
+ const delayScheduler = useScheduler();
+
+ const nextPath = useMemo(
+ () => getNavigationBase(connectedToDaemon, loginState),
+ [connectedToDaemon, loginState],
+ );
+
+ const updatePath = useEffectEvent((nextPath: RoutePath) => {
+ const currentPath = location.pathname as RoutePath;
+
+ if (currentPath !== nextPath) {
+ delayScheduler.cancel();
+
+ const transition = getNavigationTransition(currentPath, nextPath);
+ const delay = getNavigationDelay(currentPath, nextPath);
+
+ const navigate = () => {
+ reset(nextPath, { transition });
+ };
+
+ if (delay) {
+ delayScheduler.schedule(navigate, delay);
+ } else {
+ navigate();
+ }
+ }
+ });
+
+ useEffect(() => {
+ updatePath(nextPath);
+ // eslint-disable-next-line react-compiler/react-compiler
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [nextPath]);
+
+ return null;
+}
+
+function getNavigationDelay(currentPath: RoutePath, nextPath: RoutePath): number | void {
+ if (
+ currentPath === RoutePath.login &&
+ (nextPath === RoutePath.main || nextPath === RoutePath.expired)
+ ) {
+ return 1000;
+ }
+}
+
+function getNavigationTransition(currentPath: RoutePath, nextPath: RoutePath) {
+ // First level contains the possible next locations and the second level contains the
+ // possible current locations.
+ const navigationTransitions: Partial<
+ Record<RoutePath, Partial<Record<RoutePath | '*', TransitionType>>>
+ > = {
+ [RoutePath.launch]: {
+ [RoutePath.login]: TransitionType.pop,
+ [RoutePath.main]: TransitionType.pop,
+ '*': TransitionType.dismiss,
+ },
+ [RoutePath.login]: {
+ [RoutePath.launch]: TransitionType.push,
+ [RoutePath.main]: TransitionType.pop,
+ [RoutePath.deviceRevoked]: TransitionType.pop,
+ [RoutePath.tooManyDevices]: TransitionType.pop,
+ '*': TransitionType.dismiss,
+ },
+ [RoutePath.main]: {
+ [RoutePath.launch]: TransitionType.push,
+ [RoutePath.login]: TransitionType.push,
+ [RoutePath.tooManyDevices]: TransitionType.push,
+ '*': TransitionType.dismiss,
+ },
+ [RoutePath.expired]: {
+ [RoutePath.launch]: TransitionType.push,
+ [RoutePath.login]: TransitionType.push,
+ [RoutePath.tooManyDevices]: TransitionType.push,
+ '*': TransitionType.dismiss,
+ },
+ [RoutePath.timeAdded]: {
+ [RoutePath.expired]: TransitionType.push,
+ [RoutePath.redeemVoucher]: TransitionType.push,
+ '*': TransitionType.dismiss,
+ },
+ [RoutePath.deviceRevoked]: {
+ '*': TransitionType.pop,
+ },
+ [RoutePath.tooManyDevices]: {
+ [RoutePath.login]: TransitionType.push,
+ },
+ };
+
+ return navigationTransitions[nextPath]?.[currentPath] ?? navigationTransitions[nextPath]?.['*'];
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/functions/navigation-base.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/functions/navigation-base.ts
new file mode 100644
index 0000000000..7802ace1e9
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/functions/navigation-base.ts
@@ -0,0 +1,29 @@
+import { RoutePath } from '../../../shared/routes';
+import { LoginState } from '../../redux/account/reducers';
+
+export function getNavigationBase(connectedToDaemon: boolean, loginState: LoginState): RoutePath {
+ if (connectedToDaemon) {
+ if (loginState.type === 'none' && loginState.deviceRevoked) {
+ return RoutePath.deviceRevoked;
+ } else if (
+ loginState.type === 'too many devices' ||
+ (loginState.type === 'failed' && loginState.error === 'too-many-devices')
+ ) {
+ return RoutePath.tooManyDevices;
+ } else if (
+ loginState.type === 'none' ||
+ loginState.type === 'logging in' ||
+ loginState.type === 'failed'
+ ) {
+ return RoutePath.login;
+ } else if (loginState.type === 'ok' && loginState.expiredState === 'expired') {
+ return RoutePath.expired;
+ } else if (loginState.type === 'ok' && loginState.expiredState === 'time_added') {
+ return RoutePath.timeAdded;
+ } else {
+ return RoutePath.main;
+ }
+ } else {
+ return RoutePath.launch;
+ }
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/routeHelpers.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/routeHelpers.ts
index e610a2ac77..3f9c95f7a4 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/routeHelpers.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/routeHelpers.ts
@@ -4,18 +4,6 @@ import { RoutePath } from '../../shared/routes';
export type GeneratedRoutePath = { routePath: string };
-export const disableDismissForRoutes = [
- RoutePath.launch,
- RoutePath.login,
- RoutePath.tooManyDevices,
- RoutePath.deviceRevoked,
- RoutePath.main,
- RoutePath.redeemVoucher,
- RoutePath.voucherSuccess,
- RoutePath.timeAdded,
- RoutePath.setupFinished,
-];
-
export function generateRoutePath(
routePath: RoutePath,
parameters: Parameters<typeof generatePath>[1],
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/transition-hooks.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/transition-hooks.ts
index e48048c058..cf8dad0e6e 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/transition-hooks.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/transition-hooks.ts
@@ -1,6 +1,5 @@
import { Location } from 'history';
import { useCallback, useEffect, useRef, useState } from 'react';
-import { flushSync } from 'react-dom';
import { ViewTransition } from '../../../types/global';
import { LocationState } from '../../shared/ipc-types';
@@ -47,24 +46,22 @@ export function useViewTransitions(onTransition?: () => void): Location<Location
return;
}
- flushSync(() => {
- viewTransitionRef.current = document.startViewTransition(() => {
- updateView(location);
- });
+ viewTransitionRef.current = document.startViewTransition(() => {
+ updateView(location);
+ });
- void viewTransitionRef.current.ready.then(() => animateNavigation(transition));
- void viewTransitionRef.current.finished.then(() => {
- const queueLocation = queuedLocationRef.current;
+ void viewTransitionRef.current.ready.then(() => animateNavigation(transition));
+ void viewTransitionRef.current.finished.then(() => {
+ const queueLocation = queuedLocationRef.current;
- delete viewTransitionRef.current;
- delete queuedLocationRef.current;
+ delete viewTransitionRef.current;
+ delete queuedLocationRef.current;
- if (queueLocation) {
- transitionToView(queueLocation.location, queueLocation.transition);
- } else {
- onTransition?.();
- }
- });
+ if (queueLocation) {
+ transitionToView(queueLocation.location, queueLocation.transition);
+ } else {
+ onTransition?.();
+ }
});
},
);
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/account/reducers.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/account/reducers.ts
index a5cf1611ac..46b6117ae5 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/redux/account/reducers.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/account/reducers.ts
@@ -130,7 +130,14 @@ export default function (
if (status.type === 'ok') {
if (action.expired) {
status.expiredState = 'expired';
- } else if (status.expiredState === 'expired' && !action.expired) {
+ } else if (
+ status.expiredState === 'expired' &&
+ !action.expired &&
+ // If the system clock changes from something that makes the expiry out of time, backwards
+ // to something that is before the expiry, then the time added view shouldn't be displayed
+ // since the expiry hasn't changed.
+ state.expiry !== action.expiry
+ ) {
status.expiredState = 'time_added';
} else {
status.expiredState = undefined;
diff --git a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/tunnel-state.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/tunnel-state.spec.ts
index d6b25b0d99..99eb0bf517 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/tunnel-state.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/tunnel-state.spec.ts
@@ -3,6 +3,7 @@ import { exec as execAsync } from 'child_process';
import { Page } from 'playwright';
import { promisify } from 'util';
+import { RoutePath } from '../../../../src/shared/routes';
import { RoutesObjectModel } from '../../route-object-models';
import { expectConnected, expectDisconnected, expectError } from '../../shared/tunnel-state';
import { escapeRegExp, TestUtils } from '../../utils';
@@ -106,10 +107,6 @@ test.describe('Tunnel state and settings', () => {
await exec('mullvad connect --wait');
});
- test.afterAll(async () => {
- await routes.wireguardSettings.gotoRoot();
- });
-
test('App should show UDP', async () => {
await expectConnected(page);
await routes.main.expandConnectionPanel();
@@ -126,7 +123,7 @@ test.describe('Tunnel state and settings', () => {
await routes.wireguardSettings.selectUdpOverTcp();
await expect(udpOverTcpOption).toHaveAttribute('aria-selected', 'true');
- await routes.wireguardSettings.gotoRoot();
+ await routes.wireguardSettings.goBackToRoute(RoutePath.main);
await expectConnected(page);
@@ -140,7 +137,9 @@ test.describe('Tunnel state and settings', () => {
test(`App should show port ${port}`, async () => {
await gotoUdpOverTcpSettings();
await routes.udpOverTcpSettings.selectPort(port);
- await routes.udpOverTcpSettings.gotoRoot();
+
+ await routes.udpOverTcpSettings.goBackToRoute(RoutePath.main);
+
await routes.main.expandConnectionPanel();
const inValue = await routes.main.getInIpText();
@@ -154,6 +153,7 @@ test.describe('Tunnel state and settings', () => {
const automaticOption = routes.wireguardSettings.getAutomaticObfuscationOption();
await expect(automaticOption).toHaveAttribute('aria-selected', 'true');
+ await routes.udpOverTcpSettings.goBackToRoute(RoutePath.main);
});
});
diff --git a/desktop/packages/mullvad-vpn/test/e2e/lib/path-helpers.ts b/desktop/packages/mullvad-vpn/test/e2e/lib/path-helpers.ts
index 5fe0573084..bd8593dc88 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/lib/path-helpers.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/lib/path-helpers.ts
@@ -1,5 +1,16 @@
import { expect } from '@playwright/test';
+import { RoutePath } from '../../../src/shared/routes';
+
+export function generatePath(path: RoutePath, params: Record<string, string>): string {
+ return Object.entries(params)
+ .reduce(
+ (path, [name, value]) => path.replace(new RegExp(`:${name}\\??`), value),
+ path as string,
+ )
+ .replaceAll(new RegExp('/:.*?\\?', 'g'), '');
+}
+
// Match the actual path against against the expected path where the expected can contain parameters
function toMatchPath(actual: string, expected: string | null) {
const pass = matchPaths(expected, actual);
@@ -18,10 +29,6 @@ function trimTrailingSlash(value: string): string {
// Match b against a where a can contain parameters
export function matchPaths(a: string | null, b: string | null): boolean {
- if (b?.includes(':')) {
- throw new Error('Only a is allowed to contain parameters');
- }
-
if (a === null || b === null) {
return a === b;
}
@@ -29,6 +36,10 @@ export function matchPaths(a: string | null, b: string | null): boolean {
const aParts = trimTrailingSlash(a).split('/');
const bParts = trimTrailingSlash(b).split('/');
+ if (bParts.some((part) => part.startsWith(':'))) {
+ throw new Error('Only first argument is allowed to contain dynamic route path segments');
+ }
+
return (
aParts.length >= bParts.length &&
aParts.every((aPart, i) => {
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/account-expiry.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/account-expiry.spec.ts
new file mode 100644
index 0000000000..bd8f7b0317
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/account-expiry.spec.ts
@@ -0,0 +1,170 @@
+import { test } from '@playwright/test';
+import { Page } from 'playwright';
+
+import { RoutesObjectModel } from '../route-object-models';
+import { MockedTestUtils, startMockedApp } from './mocked-utils';
+
+let page: Page;
+let util: MockedTestUtils;
+let routes: RoutesObjectModel;
+
+const START_DATE = new Date('2025-01-01T13:37:00');
+
+const CLOSE_TO_EXPIRY_DIFF = 60 * 60 * 1000;
+const CLOSE_TO_EXPIRY_EXPIRY = {
+ expiry: new Date(START_DATE.getTime() + CLOSE_TO_EXPIRY_DIFF).toISOString(),
+};
+const PASSED_EXPIRY = { expiry: new Date(START_DATE.getTime() - 60 * 1000).toISOString() };
+const FUTURE_EXPIRY_DIFF = 30 * 24 * 60 * 60 * 1000;
+const FUTURE_EXPIRY = {
+ expiry: new Date(START_DATE.getTime() + FUTURE_EXPIRY_DIFF).toISOString(),
+};
+
+test.describe.configure({ mode: 'parallel' });
+
+test.describe('Account expiry', () => {
+ const startup = async () => {
+ ({ page, util } = await startMockedApp());
+ routes = new RoutesObjectModel(page, util);
+ };
+
+ test.beforeAll(async () => {
+ await startup();
+ await routes.main.waitForRoute();
+ });
+
+ test.afterAll(async () => {
+ await page.close();
+ });
+
+ test.beforeEach(async () => {
+ await page.clock.install({ time: START_DATE });
+ });
+
+ test('Should expire', async () => {
+ await util.ipc.account[''].notify(CLOSE_TO_EXPIRY_EXPIRY);
+ await routes.main.waitForRoute();
+ await page.clock.fastForward(CLOSE_TO_EXPIRY_DIFF + 1);
+ await routes.expired.waitForRoute();
+ });
+
+ // These tests verify that the renderer process will handle receiving the same expiry as
+ // previously but at different system times, where for one system time the expiry is passed but
+ // not for the other. This can happen if the system clock is changed.
+ test.describe('Handle system clock changes', () => {
+ test('Should move clock back', async () => {
+ const expiry = {
+ expiry: new Date('2025-04-03T13:00:00').toISOString(),
+ };
+
+ await page.clock.setSystemTime('2025-04-03T14:00:00');
+ await util.ipc.account[''].notify(expiry);
+
+ await routes.expired.waitForRoute();
+ await page.clock.setSystemTime('2025-01-01T12:00');
+ await util.ipc.account[''].notify(expiry);
+ await routes.main.waitForRoute();
+ });
+
+ test('Should move clock forward', async () => {
+ const expiry = {
+ expiry: new Date('2025-04-03T13:00:00').toISOString(),
+ };
+
+ await page.clock.setSystemTime('2025-04-03T12:00:00');
+ await util.ipc.account[''].notify(expiry);
+
+ await routes.main.waitForRoute();
+ await page.clock.setSystemTime('2025-04-04T12:00');
+ await util.ipc.account[''].notify(expiry);
+ await routes.expired.waitForRoute();
+ });
+ });
+
+ function addTimeTests(newAccount: boolean) {
+ test('Should respond to time added', async () => {
+ await page.clock.fastForward('02:00');
+
+ await Promise.all([
+ routes.timeAdded.waitForRoute(),
+ util.ipc.account[''].notify(FUTURE_EXPIRY),
+ ]);
+
+ await routes.timeAdded.gotoNext();
+
+ if (newAccount) {
+ await routes.setupFinished.waitForRoute();
+ await routes.setupFinished.startUsingTheApp();
+ } else {
+ await routes.main.waitForRoute();
+ }
+ });
+
+ test('Should redeem voucher', async () => {
+ await page.clock.fastForward('20:00');
+
+ const secondsAdded = FUTURE_EXPIRY_DIFF / 1000;
+
+ await util.ipc.account.submitVoucher.handle({
+ type: 'success',
+ newExpiry: FUTURE_EXPIRY.expiry,
+ secondsAdded,
+ });
+
+ await routes.expired.gotoRedeemVoucher();
+ await routes.redeemVoucher.fillVoucherInput('1234-5678-90AB-CDEF');
+ await page.clock.fastForward('02:00');
+
+ await routes.redeemVoucher.redeemVoucher();
+ await routes.voucherSuccess.waitForRoute(FUTURE_EXPIRY.expiry, secondsAdded);
+ await routes.voucherSuccess.gotoNext();
+
+ if (newAccount) {
+ await routes.setupFinished.waitForRoute();
+ await routes.setupFinished.startUsingTheApp();
+ } else {
+ await routes.main.waitForRoute();
+ }
+ });
+ }
+
+ test.describe('Has expired', () => {
+ test.beforeEach(async () => {
+ await util.ipc.account[''].notify(PASSED_EXPIRY);
+ await routes.expired.waitForRoute();
+ });
+
+ addTimeTests(false);
+ });
+
+ test.describe('New account', () => {
+ const logout = async () => {
+ await util.ipc.account.device.notify({
+ type: 'logged out',
+ deviceState: { type: 'logged out' },
+ });
+
+ await routes.login.waitForRoute();
+ };
+
+ test.beforeEach(async () => {
+ await logout();
+ await util.ipc.account.create.handle('1234213412341234');
+ await routes.login.createNewAccount();
+ await util.ipc.account[''].notify({ expiry: START_DATE.toISOString() });
+ await util.ipc.account.device.notify({
+ type: 'logged in',
+ deviceState: {
+ type: 'logged in',
+ accountAndDevice: {
+ accountNumber: '1234213413241234',
+ device: { id: '1', name: 'Successful Test', created: START_DATE },
+ },
+ },
+ });
+ await routes.expired.waitForRoute();
+ });
+
+ addTimeTests(true);
+ });
+});
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/device-revoked.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/device-revoked.spec.ts
new file mode 100644
index 0000000000..482cb523eb
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/device-revoked.spec.ts
@@ -0,0 +1,64 @@
+import { test } from '@playwright/test';
+import { Page } from 'playwright';
+
+import { RoutesObjectModel } from '../route-object-models';
+import { MockedTestUtils, startMockedApp } from './mocked-utils';
+
+let page: Page;
+let util: MockedTestUtils;
+let routes: RoutesObjectModel;
+
+test.describe.configure({ mode: 'parallel' });
+
+test.describe('Device revoked', () => {
+ test.beforeAll(async () => {
+ ({ page, util } = await startMockedApp());
+ routes = new RoutesObjectModel(page, util);
+ await routes.main.waitForRoute();
+ });
+
+ test.afterAll(async () => {
+ await page.close();
+ });
+
+ async function revokeDevice() {
+ await util.ipc.account.device.notify({ type: 'revoked', deviceState: { type: 'revoked' } });
+ await routes.deviceRevoked.waitForRoute();
+ }
+
+ test('Should navigate to device revoked view from main', async () => {
+ await revokeDevice();
+ });
+
+ test.describe('Navigation from device revoked', () => {
+ test.beforeEach(async () => {
+ await revokeDevice();
+ });
+
+ test('Should navigate back to login view', async () => {
+ await util.ipc.account.device.notify({
+ type: 'logged out',
+ deviceState: { type: 'logged out' },
+ });
+ await routes.login.waitForRoute();
+ });
+
+ test('Should navigate back to main view', async () => {
+ await util.ipc.account.device.notify({
+ type: 'logged in',
+ deviceState: {
+ type: 'logged in',
+ accountAndDevice: {
+ accountNumber: '1234123412341234',
+ device: {
+ id: '1',
+ name: 'Test',
+ created: new Date(),
+ },
+ },
+ },
+ });
+ await routes.main.waitForRoute();
+ });
+ });
+});
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators/feature-indicators.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators/feature-indicators.spec.ts
index 72fb1e7d61..b7786a983f 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators/feature-indicators.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators/feature-indicators.spec.ts
@@ -165,8 +165,7 @@ test.describe('Feature indicators', () => {
test.afterEach(async () => {
await helpers.disconnect();
- await routes.wireguardSettings.gotoRoot();
- await util.expectRoute(RoutePath.main);
+ await routes.wireguardSettings.goBackToRoute(RoutePath.main);
});
async function expectFeatureIndicators(expectedIndicators: Array<string>, only = true) {
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/launch.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/launch.spec.ts
index f93a4d33e3..5a4bfb773d 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/mocked/launch.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/launch.spec.ts
@@ -1,7 +1,6 @@
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';
@@ -13,7 +12,7 @@ test.describe('Launch', () => {
test.beforeAll(async () => {
({ page, util } = await startMockedApp());
routes = new RoutesObjectModel(page, util);
- await util.expectRoute(RoutePath.main);
+ await routes.main.waitForRoute();
await util.ipc.daemon.disconnected.notify();
await routes.launch.waitForRoute();
@@ -56,4 +55,9 @@ test.describe('Launch', () => {
await expect(gotoSystemSettingsButton).toBeVisible();
});
});
+
+ test('Should navigate to main after establishing connection to daemon', async () => {
+ await util.ipc.daemon.connected.notify();
+ await routes.main.waitForRoute();
+ });
});
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 2d309cb490..1cffac8759 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts
@@ -42,13 +42,22 @@ test.describe('Login view', () => {
await util.ipc.accountHistory[''].notify('1234123412341234');
};
- test('Should try to login when clicking login button', async () => {
+ test('Should login when clicking login button', async () => {
await routes.login.fillAccountNumber('1234 1234 1234 1234');
await Promise.all([util.ipc.account.login.expect(), routes.login.loginByClickingLoginButton()]);
const header = routes.login.selectors.header();
await expect(header).toHaveText('Logging in...');
await expect(routes.login.selectors.loginButton()).toBeDisabled();
+
+ await util.ipc.account.device.notify({
+ type: 'logged in',
+ deviceState: { type: 'logged in', accountAndDevice: { accountNumber: '1234123412341234' } },
+ });
+ await util.ipc.account[''].notify({ expiry: new Date(Date.now() + 60 * 1000).toISOString() });
+
+ await expect(header).toHaveText('Logged in');
+ await routes.main.waitForRoute();
});
test('Should try to login when pressing enter', async () => {
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/too-many-devices.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/too-many-devices.spec.ts
new file mode 100644
index 0000000000..76e3fddee3
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/too-many-devices.spec.ts
@@ -0,0 +1,68 @@
+import { test } from '@playwright/test';
+import { Page } from 'playwright';
+
+import { RoutesObjectModel } from '../route-object-models';
+import { MockedTestUtils, startMockedApp } from './mocked-utils';
+
+let page: Page;
+let util: MockedTestUtils;
+let routes: RoutesObjectModel;
+
+test.describe('Too many devices', () => {
+ test.beforeAll(async () => {
+ ({ page, util } = await startMockedApp());
+ routes = new RoutesObjectModel(page, util);
+ await routes.main.waitForRoute();
+
+ await util.ipc.account.device.notify({
+ type: 'logged out',
+ deviceState: { type: 'logged out' },
+ });
+
+ await routes.login.waitForRoute();
+ });
+
+ test.afterAll(async () => {
+ await page.close();
+ });
+
+ test.describe('Navigation', () => {
+ test('App should navigate to too many devices view', async () => {
+ await util.ipc.account.login.handle({ type: 'error', error: 'too-many-devices' });
+ await util.ipc.account.listDevices.handle([
+ {
+ id: '1',
+ name: 'Device 1',
+ created: new Date(),
+ },
+ {
+ id: '2',
+ name: 'Device 2',
+ created: new Date(),
+ },
+ ]);
+
+ await routes.login.fillAccountNumber('1234123412341234');
+ await routes.login.loginByPressingEnter();
+
+ await routes.tooManyDevices.waitForRoute();
+ });
+
+ test('App should navigate to main via login', async () => {
+ await util.ipc.account.login.handle(undefined);
+
+ await routes.tooManyDevices.waitForRoute();
+
+ await routes.tooManyDevices.continue();
+ await routes.login.waitForRoute();
+
+ await util.ipc.account.device.notify({
+ type: 'logged in',
+ deviceState: { type: 'logged in', accountAndDevice: { accountNumber: '1234123412341234' } },
+ });
+ await util.ipc.account[''].notify({ expiry: new Date(Date.now() + 60 * 1000).toISOString() });
+
+ await routes.main.waitForRoute();
+ });
+ });
+});
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/device-revoked/device-revoked-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/device-revoked/device-revoked-route-object-model.ts
new file mode 100644
index 0000000000..3cdee4e402
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/device-revoked/device-revoked-route-object-model.ts
@@ -0,0 +1,10 @@
+import { RoutePath } from '../../../../src/shared/routes';
+import { TestUtils } from '../../utils';
+
+export class DeviceRevokedRouteObjectModel {
+ constructor(private readonly utils: TestUtils) {}
+
+ async waitForRoute() {
+ await this.utils.expectRoute(RoutePath.deviceRevoked);
+ }
+}
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/device-revoked/index.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/device-revoked/index.ts
new file mode 100644
index 0000000000..91691f432f
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/device-revoked/index.ts
@@ -0,0 +1 @@
+export * from './device-revoked-route-object-model';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/expired/expired-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/expired/expired-route-object-model.ts
new file mode 100644
index 0000000000..d1449690c7
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/expired/expired-route-object-model.ts
@@ -0,0 +1,25 @@
+import { Page } from 'playwright';
+
+import { RoutePath } from '../../../../src/shared/routes';
+import { TestUtils } from '../../utils';
+import { createSelectors } from './selectors';
+
+export class ExpiredRouteObjectModel {
+ readonly selectors: ReturnType<typeof createSelectors>;
+
+ constructor(
+ private readonly page: Page,
+ private readonly utils: TestUtils,
+ ) {
+ this.selectors = createSelectors(this.page);
+ }
+
+ async waitForRoute() {
+ await this.utils.expectRoute(RoutePath.expired);
+ }
+
+ async gotoRedeemVoucher() {
+ await this.selectors.redeemVoucherButton().click();
+ await this.utils.expectRoute(RoutePath.redeemVoucher);
+ }
+}
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/expired/index.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/expired/index.ts
new file mode 100644
index 0000000000..367b039480
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/expired/index.ts
@@ -0,0 +1,2 @@
+export * from './expired-route-object-model';
+export * from './selectors';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/expired/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/expired/selectors.ts
new file mode 100644
index 0000000000..71b652d724
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/expired/selectors.ts
@@ -0,0 +1,5 @@
+import { Page } from 'playwright';
+
+export const createSelectors = (page: Page) => ({
+ redeemVoucherButton: () => page.getByRole('button', { name: 'Redeem voucher' }),
+});
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/navigation/navigation-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/navigation/navigation-object-model.ts
index 912142c737..36d3aba6cc 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/navigation/navigation-object-model.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/navigation/navigation-object-model.ts
@@ -1,5 +1,7 @@
import { Page } from 'playwright';
+import { RoutePath } from '../../../../src/shared/routes';
+import { matchPaths } from '../../lib/path-helpers';
import { TestUtils } from '../../utils';
import { createSelectors } from './selectors';
@@ -17,7 +19,15 @@ export class NavigationObjectModel {
await this.utils.expectRouteChange(() => this.navigationSelectors.backButton().click());
}
- async gotoRoot() {
- await this.page.press('body', 'Shift+Escape');
+ async goBackToRoute(route: RoutePath) {
+ const currentRoute = await this.utils.getCurrentRoute();
+ if (!matchPaths(route, currentRoute)) {
+ if (await this.navigationSelectors.backButton().isVisible()) {
+ await this.goBack();
+ await this.goBackToRoute(route);
+ } else {
+ await this.utils.expectRoute(route);
+ }
+ }
}
}
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/redeem-voucher/index.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/redeem-voucher/index.ts
new file mode 100644
index 0000000000..f619ccf099
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/redeem-voucher/index.ts
@@ -0,0 +1,2 @@
+export * from './redeem-voucher-route-object-model';
+export * from './selectors';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/redeem-voucher/redeem-voucher-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/redeem-voucher/redeem-voucher-route-object-model.ts
new file mode 100644
index 0000000000..95afe8d5ad
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/redeem-voucher/redeem-voucher-route-object-model.ts
@@ -0,0 +1,28 @@
+import { Page } from 'playwright';
+
+import { RoutePath } from '../../../../src/shared/routes';
+import { TestUtils } from '../../utils';
+import { createSelectors } from './selectors';
+
+export class RedeemVoucherRouteObjectModel {
+ readonly selectors: ReturnType<typeof createSelectors>;
+
+ constructor(
+ private readonly page: Page,
+ private readonly utils: TestUtils,
+ ) {
+ this.selectors = createSelectors(this.page);
+ }
+
+ async waitForRoute() {
+ await this.utils.expectRoute(RoutePath.redeemVoucher);
+ }
+
+ async fillVoucherInput(accountNumber: string) {
+ await this.selectors.voucherInput().fill(accountNumber);
+ }
+
+ async redeemVoucher() {
+ await this.selectors.redeemButton().click();
+ }
+}
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/redeem-voucher/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/redeem-voucher/selectors.ts
new file mode 100644
index 0000000000..c0532e9f3c
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/redeem-voucher/selectors.ts
@@ -0,0 +1,6 @@
+import { Page } from 'playwright';
+
+export const createSelectors = (page: Page) => ({
+ voucherInput: () => page.getByPlaceholder('XXXX-XXXX-XXXX-XXXX'),
+ redeemButton: () => page.getByRole('button', { name: 'Redeem' }),
+});
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/routes-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/routes-object-model.ts
index 997247d2fa..77ec121306 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/routes-object-model.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/routes-object-model.ts
@@ -2,18 +2,25 @@ import { Page } from 'playwright';
import { TestUtils } from '../utils';
import { DaitaSettingsRouteObjectModel } from './daita-settings';
+import { DeviceRevokedRouteObjectModel } from './device-revoked';
+import { ExpiredRouteObjectModel } from './expired';
import { FilterRouteObjectModel } from './filter';
import { LaunchRouteObjectModel } from './launch';
import { LoginRouteObjectModel } from './login';
import { MainRouteObjectModel } from './main';
import { MultihopSettingsRouteObjectModel } from './multihop-settings';
+import { RedeemVoucherRouteObjectModel } from './redeem-voucher';
import { SelectLanguageRouteObjectModel } from './select-language';
import { SelectLocationRouteObjectModel } from './select-location';
import { SettingsRouteObjectModel } from './settings/settings-route-object-model';
+import { SetupFinishedRouteObjectModel } from './setup-finished';
import { ShadowsocksSettingsRouteObjectModel } from './shadowsocks-settings';
import { SplitTunnelingSettingsRouteObjectModel } from './split-tunneling-settings';
+import { TimeAddedRouteObjectModel } from './time-added';
+import { TooManyDevicesRouteObjectModel } from './too-many-devices';
import { UdpOverTcpSettingsRouteObjectModel } from './udp-over-tcp-settings';
import { UserInterfaceSettingsRouteObjectModel } from './user-interface-settings';
+import { VoucherSuccessRouteObjectModel } from './voucher-success';
import { VpnSettingsRouteObjectModel } from './vpn-settings';
import { WireguardSettingsRouteObjectModel } from './wireguard-settings';
@@ -21,6 +28,13 @@ export class RoutesObjectModel {
readonly main: MainRouteObjectModel;
readonly launch: LaunchRouteObjectModel;
readonly login: LoginRouteObjectModel;
+ readonly expired: ExpiredRouteObjectModel;
+ readonly redeemVoucher: RedeemVoucherRouteObjectModel;
+ readonly voucherSuccess: VoucherSuccessRouteObjectModel;
+ readonly timeAdded: TimeAddedRouteObjectModel;
+ readonly setupFinished: SetupFinishedRouteObjectModel;
+ readonly deviceRevoked: DeviceRevokedRouteObjectModel;
+ readonly tooManyDevices: TooManyDevicesRouteObjectModel;
readonly settings: SettingsRouteObjectModel;
readonly userInterfaceSettings: UserInterfaceSettingsRouteObjectModel;
readonly selectLanguage: SelectLanguageRouteObjectModel;
@@ -39,6 +53,13 @@ export class RoutesObjectModel {
this.main = new MainRouteObjectModel(page, utils);
this.launch = new LaunchRouteObjectModel(page, utils);
this.login = new LoginRouteObjectModel(page, utils);
+ this.expired = new ExpiredRouteObjectModel(page, utils);
+ this.redeemVoucher = new RedeemVoucherRouteObjectModel(page, utils);
+ this.voucherSuccess = new VoucherSuccessRouteObjectModel(page, utils);
+ this.timeAdded = new TimeAddedRouteObjectModel(page, utils);
+ this.setupFinished = new SetupFinishedRouteObjectModel(page, utils);
+ this.deviceRevoked = new DeviceRevokedRouteObjectModel(utils);
+ this.tooManyDevices = new TooManyDevicesRouteObjectModel(page, utils);
this.settings = new SettingsRouteObjectModel(page, utils);
this.userInterfaceSettings = new UserInterfaceSettingsRouteObjectModel(page, utils);
this.filter = new FilterRouteObjectModel(page, utils);
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/setup-finished/index.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/setup-finished/index.ts
new file mode 100644
index 0000000000..9a107b55b6
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/setup-finished/index.ts
@@ -0,0 +1,2 @@
+export * from './setup-finished-route-object-model';
+export * from './selectors';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/setup-finished/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/setup-finished/selectors.ts
new file mode 100644
index 0000000000..d5f2d8330e
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/setup-finished/selectors.ts
@@ -0,0 +1,5 @@
+import { Page } from 'playwright';
+
+export const createSelectors = (page: Page) => ({
+ startUsingTheAppButton: () => page.getByRole('button', { name: 'Start using the app' }),
+});
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/setup-finished/setup-finished-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/setup-finished/setup-finished-route-object-model.ts
new file mode 100644
index 0000000000..f7aeceb325
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/setup-finished/setup-finished-route-object-model.ts
@@ -0,0 +1,25 @@
+import { Page } from 'playwright';
+
+import { RoutePath } from '../../../../src/shared/routes';
+import { TestUtils } from '../../utils';
+import { createSelectors } from './selectors';
+
+export class SetupFinishedRouteObjectModel {
+ readonly selectors: ReturnType<typeof createSelectors>;
+
+ constructor(
+ private readonly page: Page,
+ private readonly utils: TestUtils,
+ ) {
+ this.selectors = createSelectors(this.page);
+ }
+
+ async waitForRoute() {
+ await this.utils.expectRoute(RoutePath.setupFinished);
+ }
+
+ async startUsingTheApp() {
+ await this.selectors.startUsingTheAppButton().click();
+ await this.utils.expectRoute(RoutePath.main);
+ }
+}
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/shadowsocks-settings/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/shadowsocks-settings/selectors.ts
index 6d7844396d..87b22ad6f7 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/shadowsocks-settings/selectors.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/shadowsocks-settings/selectors.ts
@@ -6,8 +6,6 @@ export const createSelectors = (page: Page) => ({
.getByRole('listbox', { name: 'Port' })
.getByRole('option', { name: 'Automatic', exact: true }),
customPortOption: () =>
- page
- .getByRole('listbox', { name: 'Port' })
- .getByRole('option', { name: 'Custom', exact: true }),
+ page.getByRole('listbox', { name: 'Port' }).getByRole('option', { name: 'Custom' }),
portInput: () => page.getByPlaceholder('Port'),
});
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/time-added/index.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/time-added/index.ts
new file mode 100644
index 0000000000..2d4afd7d8f
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/time-added/index.ts
@@ -0,0 +1,2 @@
+export * from './time-added-route-object-model';
+export * from './selectors';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/time-added/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/time-added/selectors.ts
new file mode 100644
index 0000000000..6394901191
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/time-added/selectors.ts
@@ -0,0 +1,5 @@
+import { Page } from 'playwright';
+
+export const createSelectors = (page: Page) => ({
+ nextButton: () => page.getByRole('button', { name: 'Next' }),
+});
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/time-added/time-added-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/time-added/time-added-route-object-model.ts
new file mode 100644
index 0000000000..5bad1862db
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/time-added/time-added-route-object-model.ts
@@ -0,0 +1,24 @@
+import { Page } from 'playwright';
+
+import { RoutePath } from '../../../../src/shared/routes';
+import { TestUtils } from '../../utils';
+import { createSelectors } from './selectors';
+
+export class TimeAddedRouteObjectModel {
+ readonly selectors: ReturnType<typeof createSelectors>;
+
+ constructor(
+ private readonly page: Page,
+ private readonly utils: TestUtils,
+ ) {
+ this.selectors = createSelectors(this.page);
+ }
+
+ async waitForRoute() {
+ await this.utils.expectRoute(RoutePath.timeAdded);
+ }
+
+ async gotoNext() {
+ await this.selectors.nextButton().click();
+ }
+}
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/too-many-devices/index.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/too-many-devices/index.ts
new file mode 100644
index 0000000000..47ab585d08
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/too-many-devices/index.ts
@@ -0,0 +1,2 @@
+export * from './too-many-devices-route-object-model';
+export * from './selectors';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/too-many-devices/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/too-many-devices/selectors.ts
new file mode 100644
index 0000000000..12474f3ad9
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/too-many-devices/selectors.ts
@@ -0,0 +1,5 @@
+import { Page } from 'playwright';
+
+export const createSelectors = (page: Page) => ({
+ continueButton: () => page.getByRole('button', { name: 'Continue' }),
+});
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/too-many-devices/too-many-devices-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/too-many-devices/too-many-devices-route-object-model.ts
new file mode 100644
index 0000000000..fe0f8c10ed
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/too-many-devices/too-many-devices-route-object-model.ts
@@ -0,0 +1,24 @@
+import { Page } from 'playwright';
+
+import { RoutePath } from '../../../../src/shared/routes';
+import { TestUtils } from '../../utils';
+import { createSelectors } from './selectors';
+
+export class TooManyDevicesRouteObjectModel {
+ readonly selectors: ReturnType<typeof createSelectors>;
+
+ constructor(
+ private readonly page: Page,
+ private readonly utils: TestUtils,
+ ) {
+ this.selectors = createSelectors(this.page);
+ }
+
+ async waitForRoute() {
+ await this.utils.expectRoute(RoutePath.tooManyDevices);
+ }
+
+ async continue() {
+ await this.selectors.continueButton().click();
+ }
+}
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/voucher-success/index.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/voucher-success/index.ts
new file mode 100644
index 0000000000..45ad932e1f
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/voucher-success/index.ts
@@ -0,0 +1,2 @@
+export * from './voucher-success-route-object-model';
+export * from './selectors';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/voucher-success/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/voucher-success/selectors.ts
new file mode 100644
index 0000000000..6394901191
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/voucher-success/selectors.ts
@@ -0,0 +1,5 @@
+import { Page } from 'playwright';
+
+export const createSelectors = (page: Page) => ({
+ nextButton: () => page.getByRole('button', { name: 'Next' }),
+});
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/voucher-success/voucher-success-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/voucher-success/voucher-success-route-object-model.ts
new file mode 100644
index 0000000000..f07e5fa7c3
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/voucher-success/voucher-success-route-object-model.ts
@@ -0,0 +1,27 @@
+import { Page } from 'playwright';
+
+import { RoutePath } from '../../../../src/shared/routes';
+import { generatePath } from '../../lib/path-helpers';
+import { TestUtils } from '../../utils';
+import { createSelectors } from './selectors';
+
+export class VoucherSuccessRouteObjectModel {
+ readonly selectors: ReturnType<typeof createSelectors>;
+
+ constructor(
+ private readonly page: Page,
+ private readonly utils: TestUtils,
+ ) {
+ this.selectors = createSelectors(this.page);
+ }
+
+ async waitForRoute(newExpiry: string, secondsAdded: number) {
+ await this.utils.expectRoute(
+ generatePath(RoutePath.voucherSuccess, { newExpiry, secondsAdded: secondsAdded.toString() }),
+ );
+ }
+
+ async gotoNext() {
+ await this.selectors.nextButton().click();
+ }
+}
diff --git a/desktop/packages/mullvad-vpn/test/e2e/utils.ts b/desktop/packages/mullvad-vpn/test/e2e/utils.ts
index 9534c4474a..8998c23a4a 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/utils.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/utils.ts
@@ -4,8 +4,6 @@ import { expect } from '@playwright/test';
import fs from 'fs';
import { _electron as electron, ElectronApplication, Locator, Page } from 'playwright';
-import { RoutePath } from '../../src/shared/routes';
-
export interface StartAppResponse {
app: ElectronApplication;
page: Page;
@@ -16,7 +14,7 @@ type TriggerFn = () => Promise<void> | void;
export interface TestUtils {
getCurrentRoute: () => Promise<string | null>;
- expectRoute: (route: RoutePath) => Promise<void>;
+ expectRoute: (route: string) => Promise<void>;
expectRouteChange: (trigger: TriggerFn) => Promise<void>;
}
@@ -32,7 +30,7 @@ export const startApp = async (options: LaunchOptions): Promise<StartAppResponse
const util: TestUtils = {
getCurrentRoute: () => getCurrentRoute(page),
- expectRoute: (route: RoutePath) => expectRoute(page, route),
+ expectRoute: (route: string) => expectRoute(page, route),
expectRouteChange: (trigger: TriggerFn) => expectRouteChange(page, trigger),
};
@@ -49,7 +47,7 @@ function getCurrentRoute(page: Page): Promise<string | null> {
}
// Returns a promise which resolves when the provided route is reached.
-async function expectRoute(page: Page, expectedRoute: RoutePath): Promise<void> {
+async function expectRoute(page: Page, expectedRoute: string): Promise<void> {
await expect.poll(async () => getCurrentRoute(page)).toMatchPath(expectedRoute);
}
diff --git a/desktop/packages/mullvad-vpn/test/unit/path-helpers.spec.ts b/desktop/packages/mullvad-vpn/test/unit/path-helpers.spec.ts
index 0c0936c8aa..1c7a814453 100644
--- a/desktop/packages/mullvad-vpn/test/unit/path-helpers.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/unit/path-helpers.spec.ts
@@ -1,7 +1,8 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
-import { matchPaths } from '../e2e/lib/path-helpers';
+import { RoutePath } from '../../src/shared/routes';
+import { generatePath, matchPaths } from '../e2e/lib/path-helpers';
describe('E2E test path helper', () => {
it('should identify matching paths', () => {
@@ -19,6 +20,14 @@ describe('E2E test path helper', () => {
expect(matchPaths('/a/b/c', 'a/b/c')).to.be.false;
expect(matchPaths('/a/b/:param', '/a/b')).to.be.false;
- expect(() => matchPaths('/a/b/c', '/a/b/:param')).to.throw();
+ expect(() => matchPaths('/a/b/c', '/a/b/:clock')).to.throw();
+ expect(() => matchPaths('/a/b/:clock', '/a/b/20:00')).not.to.throw();
+ });
+
+ it('should correctly replace parameters', () => {
+ expect(generatePath('/a/b' as RoutePath, {})).to.equal('/a/b');
+ expect(generatePath('/a/:param' as RoutePath, { param: 'b' })).to.equal('/a/b');
+ expect(generatePath('/a/:param?' as RoutePath, { param: 'b' })).to.equal('/a/b');
+ expect(generatePath('/a/:param?' as RoutePath, {})).to.equal('/a');
});
});
diff --git a/desktop/packages/mullvad-vpn/test/unit/system-time-monitor.spec.ts b/desktop/packages/mullvad-vpn/test/unit/system-time-monitor.spec.ts
new file mode 100644
index 0000000000..f207447288
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/unit/system-time-monitor.spec.ts
@@ -0,0 +1,29 @@
+import { expect, spy } from 'chai';
+import sinon from 'sinon';
+
+import { systemTimeMonitor } from '../../src/main/system-time-monitor';
+
+describe('IAccountData cache', () => {
+ let clock: sinon.SinonFakeTimers;
+
+ beforeEach(() => {
+ clock = sinon.useFakeTimers({ shouldAdvanceTime: true });
+ });
+
+ afterEach(() => {
+ clock.restore();
+ });
+
+ it('should notify when system clock changes', () => {
+ const systemTimeListener = spy();
+
+ clock.setSystemTime(new Date('2025-01-01'));
+ systemTimeMonitor(systemTimeListener);
+ clock.setSystemTime(new Date('2025-01-02'));
+ clock.tick(1001);
+ clock.setSystemTime(new Date('2025-01-01'));
+ clock.tick(1900);
+
+ expect(systemTimeListener).to.have.been.called.twice;
+ });
+});