diff options
| author | Oskar <oskar@mullvad.net> | 2025-10-01 13:19:55 +0200 |
|---|---|---|
| committer | Oskar <oskar@mullvad.net> | 2025-10-01 13:19:55 +0200 |
| commit | 411165eaa0a8f278797f68ef9063ff3ccfd17385 (patch) | |
| tree | bdb2d40258075e88d0ec3a713c9088d119268262 | |
| parent | e6e729ec9c31d09c2d32e0f118bc4cfcc3d7b1f9 (diff) | |
| parent | 60339c6f48f932300ea0c28a290300692899272c (diff) | |
| download | mullvadvpn-411165eaa0a8f278797f68ef9063ff3ccfd17385.tar.xz mullvadvpn-411165eaa0a8f278797f68ef9063ff3ccfd17385.zip | |
Merge branch 'out-of-time-view-persists-after-adding-time-to-account-des-2339'
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; + }); +}); |
