diff options
14 files changed, 484 insertions, 98 deletions
diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot index 360a6a8482..bdc7ffe220 100644 --- a/desktop/packages/mullvad-vpn/locales/messages.pot +++ b/desktop/packages/mullvad-vpn/locales/messages.pot @@ -237,6 +237,10 @@ msgstr "" msgid "Reconnect" msgstr "" +#. Button label in confirmation dialog that confirms a remove action. +msgid "Remove" +msgstr "" + msgid "Required" msgstr "" @@ -1484,10 +1488,25 @@ msgctxt "login-view" msgid "Create account" msgstr "" +#. Button which confirms the action to create a new account. +msgctxt "login-view" +msgid "Create new account" +msgstr "" + msgctxt "login-view" msgid "Creating account..." msgstr "" +#. Text that asks the user if they really want to create a new account. +msgctxt "login-view" +msgid "Do you want to create a new account?" +msgstr "" + +#. Text that asks the user if they really want to remove the saved account. +msgctxt "login-view" +msgid "Do you want to remove the saved account number?" +msgstr "" + msgctxt "login-view" msgid "Don’t have an account number?" msgstr "" @@ -1546,6 +1565,12 @@ msgctxt "login-view" msgid "Please wait" msgstr "" +#. Text that informs the user about the consequences of clearing the saved +#. account number. +msgctxt "login-view" +msgid "Removing the saved account number from this device cannot be undone." +msgstr "" + #. Error message shown above login input when trying to login to an account #. with too many registered devices. msgctxt "login-view" @@ -1564,6 +1589,11 @@ msgctxt "login-view" msgid "Valid account number" msgstr "" +#. Text that informs the users about consequences of creating a new account. +msgctxt "login-view" +msgid "You already have a saved account number, by creating a new account the saved account number will be removed from this device. This cannot be undone." +msgstr "" + #. Title label in navigation bar msgctxt "navigation-bar" msgid "API access" @@ -2935,9 +2965,6 @@ msgstr "" msgid "Create" msgstr "" -msgid "Create new account" -msgstr "" - msgid "Create new list" msgstr "" @@ -2992,9 +3019,6 @@ msgstr "" msgid "Dismiss" msgstr "" -msgid "Do you want to create a new account?" -msgstr "" - msgid "Edit custom lists" msgstr "" @@ -3205,9 +3229,6 @@ msgstr "" msgid "Recursion limit" msgstr "" -msgid "Remove" -msgstr "" - msgid "Remove %1$s from %2$s" msgstr "" @@ -3400,9 +3421,6 @@ msgstr "" msgid "WireGuard port" msgstr "" -msgid "You already have a saved account number, by creating a new account the saved account number will be removed from this device. This cannot be undone." -msgstr "" - msgid "\"%s\" was created" msgstr "" diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx index 492199c76f..16fa8bcbda 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx @@ -2,8 +2,8 @@ import { useCallback, useRef } from 'react'; import { Route, Switch } from 'react-router'; import { RoutePath } from '../../shared/routes'; -import LoginPage from '../components/Login'; import SelectLocation from '../components/select-location/SelectLocationContainer'; +import LoginPage from '../components/views/login/Login'; import { useViewTransitions } from '../lib/transition-hooks'; import Account from './Account'; import ApiAccessMethods from './ApiAccessMethods'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/login/ClearAccountHistoryDialog.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/ClearAccountHistoryDialog.tsx new file mode 100644 index 0000000000..b2c3e370d5 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/ClearAccountHistoryDialog.tsx @@ -0,0 +1,48 @@ +import { messages } from '../../../../shared/gettext'; +import { Button } from '../../../lib/components'; +import { ModalAlert, ModalAlertType, ModalMessage } from '../../Modal'; + +interface Props { + visible: boolean; + onConfirm: () => void; + onHide: () => void; +} + +export default function ClearAccountHistoryDialog(props: Props) { + return ( + <ModalAlert + isOpen={props.visible} + type={ModalAlertType.caution} + buttons={[ + <Button variant="destructive" key="confirm" onClick={props.onConfirm}> + <Button.Text> + { + // TRANSLATORS: Button label in confirmation dialog that confirms a remove action. + messages.gettext('Remove') + } + </Button.Text> + </Button>, + <Button key="back" onClick={props.onHide}> + <Button.Text>{messages.gettext('Cancel')}</Button.Text> + </Button>, + ]} + close={props.onHide}> + <ModalMessage> + { + // TRANSLATORS: Text that informs the user about the consequences of clearing the saved + // TRANSLATORS: account number. + messages.pgettext( + 'login-view', + 'Removing the saved account number from this device cannot be undone.', + ) + } + </ModalMessage> + <ModalMessage> + { + // TRANSLATORS: Text that asks the user if they really want to remove the saved account. + messages.pgettext('login-view', 'Do you want to remove the saved account number?') + } + </ModalMessage> + </ModalAlert> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/login/CreateAccountDialog.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/CreateAccountDialog.tsx new file mode 100644 index 0000000000..a416989e46 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/CreateAccountDialog.tsx @@ -0,0 +1,47 @@ +import { messages } from '../../../../shared/gettext'; +import { Button } from '../../../lib/components'; +import { ModalAlert, ModalAlertType, ModalMessage } from '../../Modal'; + +interface Props { + visible: boolean; + onConfirm: () => void; + onHide: () => void; +} + +export default function ClearAccountHistoryDialog(props: Props) { + return ( + <ModalAlert + isOpen={props.visible} + type={ModalAlertType.caution} + buttons={[ + <Button key="confirm" onClick={props.onConfirm}> + <Button.Text> + { + // TRANSLATORS: Button which confirms the action to create a new account. + messages.pgettext('login-view', 'Create new account') + } + </Button.Text> + </Button>, + <Button key="back" onClick={props.onHide}> + <Button.Text>{messages.gettext('Cancel')}</Button.Text> + </Button>, + ]} + close={props.onHide}> + <ModalMessage> + { + // TRANSLATORS: Text that informs the users about consequences of creating a new account. + messages.pgettext( + 'login-view', + 'You already have a saved account number, by creating a new account the saved account number will be removed from this device. This cannot be undone.', + ) + } + </ModalMessage> + <ModalMessage> + { + // TRANSLATORS: Text that asks the user if they really want to create a new account. + messages.pgettext('login-view', 'Do you want to create a new account?') + } + </ModalMessage> + </ModalAlert> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/Login.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/Login.tsx index 8662c669f9..d085ea2b54 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/Login.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/Login.tsx @@ -2,22 +2,33 @@ import React, { useCallback } from 'react'; import { sprintf } from 'sprintf-js'; import styled from 'styled-components'; -import { Url } from '../../shared/constants'; -import { AccountDataError, AccountNumber } from '../../shared/daemon-rpc-types'; -import { messages } from '../../shared/gettext'; -import { useAppContext } from '../context'; -import { formatAccountNumber } from '../lib/account'; -import useActions from '../lib/actionsHook'; -import { Box, Button, Flex, Icon, Label, LabelTiny, Spinner, TitleMedium } from '../lib/components'; -import { colors } from '../lib/foundations'; -import { formatHtml } from '../lib/html-formatter'; -import { IconBadge } from '../lib/icon-badge'; -import accountActions from '../redux/account/actions'; -import { LoginState } from '../redux/account/reducers'; -import { useSelector } from '../redux/store'; -import Accordion from './Accordion'; -import { AppMainHeader } from './app-main-header'; -import { Container, Layout } from './Layout'; +import { Url } from '../../../../shared/constants'; +import { AccountDataError, AccountNumber } from '../../../../shared/daemon-rpc-types'; +import { messages } from '../../../../shared/gettext'; +import { useAppContext } from '../../../context'; +import { formatAccountNumber } from '../../../lib/account'; +import useActions from '../../../lib/actionsHook'; +import { + Box, + Button, + Flex, + Icon, + Label, + LabelTiny, + Spinner, + TitleMedium, +} from '../../../lib/components'; +import { colors } from '../../../lib/foundations'; +import { formatHtml } from '../../../lib/html-formatter'; +import { IconBadge } from '../../../lib/icon-badge'; +import accountActions from '../../../redux/account/actions'; +import { LoginState } from '../../../redux/account/reducers'; +import { useSelector } from '../../../redux/store'; +import Accordion from '../../Accordion'; +import { AppMainHeader } from '../../app-main-header'; +import { Container, Layout } from '../../Layout'; +import ClearAccountHistoryDialog from './ClearAccountHistoryDialog'; +import CreateAccountDialog from './CreateAccountDialog'; import { StyledAccountDropdownContainer, StyledAccountDropdownItem, @@ -87,6 +98,8 @@ interface IProps { interface IState { isActive: boolean; + clearAccountHistoryDialogVisible: boolean; + createAccountDialogVisible: boolean; } const MIN_ACCOUNT_NUMBER_LENGTH = 10; @@ -94,6 +107,8 @@ const MIN_ACCOUNT_NUMBER_LENGTH = 10; class Login extends React.Component<IProps, IState> { public state: IState = { isActive: true, + clearAccountHistoryDialogVisible: false, + createAccountDialogVisible: false, }; private accountInput = React.createRef<HTMLInputElement>(); @@ -290,9 +305,18 @@ class Login extends React.Component<IProps, IState> { }; private onClearAccountHistory = () => { + this.setState({ clearAccountHistoryDialogVisible: true }); + }; + + private onConfirmClearAccountHistory = () => { + this.hideClearAccountHistoryDialog(); void this.clearAccountHistory(); }; + private hideClearAccountHistoryDialog = () => { + this.setState({ clearAccountHistoryDialogVisible: false }); + }; + private async clearAccountHistory() { try { await this.props.clearAccountHistory(); @@ -303,6 +327,23 @@ class Login extends React.Component<IProps, IState> { } } + private onCreateNewAccount = () => { + if (this.props.accountHistory !== undefined) { + this.setState({ createAccountDialogVisible: true }); + } else { + this.onConfirmCreateNewAccount(); + } + }; + + private onConfirmCreateNewAccount = () => { + this.props.createNewAccount(); + this.hideCreateAccountDialog(); + }; + + private hideCreateAccountDialog = () => { + this.setState({ createAccountDialogVisible: false }); + }; + private createLoginForm() { const inputId = 'account-number-input'; const allowInteraction = this.allowInteraction(); @@ -312,72 +353,88 @@ class Login extends React.Component<IProps, IState> { this.props.loginState.method === 'existing_account'; return ( - <Flex $flexDirection="column" $gap="small"> - <Label htmlFor={inputId} data-testid="subtitle"> - {this.formSubtitle()} - </Label> - <StyledAccountInputGroup - $active={allowInteraction && this.state.isActive} - $editable={allowInteraction} - $error={hasError} - onSubmit={this.onSubmit}> - <StyledAccountInputBackdrop> - <StyledInput - id={inputId} - allowedCharacters="[0-9]" - separator=" " - groupLength={4} - placeholder="0000 0000 0000 0000" - value={this.props.accountNumber || ''} - disabled={!allowInteraction} - onFocus={this.onFocus} - onBlur={this.onBlur} - handleChange={this.onInputChange} - autoFocus={true} - ref={this.accountInput} - aria-autocomplete="list" - /> - <StyledInputButton - type="submit" - $visible={allowLogin} - disabled={!allowLogin} - aria-label={ - // TRANSLATORS: This is used by screenreaders to communicate the login button. - messages.pgettext('accessibility', 'Login') - }> - <StyledInputSubmitIcon - $visible={ - this.props.loginState.type !== 'logging in' && !this.props.isPerformingPostUpgrade - } - icon="chevron-right" - size="large" + <> + <Flex $flexDirection="column" $gap="small"> + <Label htmlFor={inputId} data-testid="subtitle"> + {this.formSubtitle()} + </Label> + <StyledAccountInputGroup + $active={allowInteraction && this.state.isActive} + $editable={allowInteraction} + $error={hasError} + onSubmit={this.onSubmit}> + <StyledAccountInputBackdrop> + <StyledInput + id={inputId} + allowedCharacters="[0-9]" + separator=" " + groupLength={4} + placeholder="0000 0000 0000 0000" + value={this.props.accountNumber || ''} + disabled={!allowInteraction} + onFocus={this.onFocus} + onBlur={this.onBlur} + handleChange={this.onInputChange} + autoFocus={true} + ref={this.accountInput} + aria-autocomplete="list" /> - </StyledInputButton> - </StyledAccountInputBackdrop> - <Accordion expanded={this.shouldShowAccountHistory()}> - <StyledAccountDropdownContainer> - <AccountDropdown - item={this.props.accountHistory} - onSelect={this.onSelectAccountFromHistory} - onRemove={this.onClearAccountHistory} - /> - </StyledAccountDropdownContainer> - </Accordion> - </StyledAccountInputGroup> - </Flex> + <StyledInputButton + type="submit" + $visible={allowLogin} + disabled={!allowLogin} + aria-label={ + // TRANSLATORS: This is used by screenreaders to communicate the login button. + messages.pgettext('accessibility', 'Login') + }> + <StyledInputSubmitIcon + $visible={ + this.props.loginState.type !== 'logging in' && + !this.props.isPerformingPostUpgrade + } + icon="chevron-right" + size="large" + /> + </StyledInputButton> + </StyledAccountInputBackdrop> + <Accordion expanded={this.shouldShowAccountHistory()}> + <StyledAccountDropdownContainer> + <AccountDropdown + item={this.props.accountHistory} + onSelect={this.onSelectAccountFromHistory} + onRemove={this.onClearAccountHistory} + /> + </StyledAccountDropdownContainer> + </Accordion> + </StyledAccountInputGroup> + </Flex> + + <ClearAccountHistoryDialog + visible={this.state.clearAccountHistoryDialogVisible} + onConfirm={this.onConfirmClearAccountHistory} + onHide={this.hideClearAccountHistoryDialog} + /> + </> ); } private createFooter() { return ( - <Flex $flexDirection="column" $gap="small"> - <LabelTiny color="whiteAlpha60"> - {messages.pgettext('login-view', 'Don’t have an account number?')} - </LabelTiny> - <Button onClick={this.props.createNewAccount} disabled={!this.allowCreateAccount()}> - <Button.Text>{messages.pgettext('login-view', 'Create account')}</Button.Text> - </Button> - </Flex> + <> + <Flex $flexDirection="column" $gap="small"> + <LabelTiny color="whiteAlpha60"> + {messages.pgettext('login-view', 'Don’t have an account number?')} + </LabelTiny> + <Button onClick={this.onCreateNewAccount} disabled={!this.allowCreateAccount()}> + <Button.Text>{messages.pgettext('login-view', 'Create account')}</Button.Text> + </Button> + </Flex> + <CreateAccountDialog + visible={this.state.createAccountDialogVisible} + onConfirm={this.onConfirmCreateNewAccount} + onHide={this.hideCreateAccountDialog} + /> + </> ); } } diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/LoginStyles.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginStyles.tsx index 68824f564c..54bdeab8b4 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/LoginStyles.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/login/LoginStyles.tsx @@ -1,10 +1,10 @@ import styled from 'styled-components'; -import { Icon } from '../lib/components'; -import { colors, spacings } from '../lib/foundations'; -import { hugeText, largeText, measurements, smallText, tinyText } from './common-styles'; -import FormattableTextInput from './FormattableTextInput'; -import { Footer } from './Layout'; +import { Icon } from '../../../lib/components'; +import { colors, spacings } from '../../../lib/foundations'; +import { hugeText, largeText, measurements, smallText, tinyText } from '../../common-styles'; +import FormattableTextInput from '../../FormattableTextInput'; +import { Footer } from '../../Layout'; export const StyledAccountDropdownContainer = styled.ul({ display: 'flex', diff --git a/desktop/packages/mullvad-vpn/src/shared/ipc-helpers.ts b/desktop/packages/mullvad-vpn/src/shared/ipc-helpers.ts index c49b7df9b8..db219f4535 100644 --- a/desktop/packages/mullvad-vpn/src/shared/ipc-helpers.ts +++ b/desktop/packages/mullvad-vpn/src/shared/ipc-helpers.ts @@ -61,6 +61,11 @@ export type IpcRenderer<S extends Schema> = { }; }; +// Transforms the provided schema into containing only the event keys. +export type IpcEvents<S> = { + [G in keyof S]: { [C in keyof S[G]]: string }; +}; + // Preforms the transformation of the main event channel in accordance with the above types. export function createIpcMain<S extends Schema>( schema: S, @@ -99,10 +104,15 @@ export function createIpcRenderer<S extends Schema>( }); } -function createIpc<S extends Schema, T, R extends IpcMain<S> | IpcRenderer<S>>( - ipc: S, - fn: (event: string, key: string, spec: AnyIpcCall) => [newKey: string, newValue: T], -): R { +export function createIpcEvents<S extends Schema>(schema: S): IpcEvents<S> { + return createIpc(schema, (event, key) => [key, event]); +} + +export function createIpc< + S extends Schema, + T, + R extends IpcMain<S> | IpcRenderer<S> | IpcEvents<S>, +>(ipc: S, fn: (event: string, key: string, spec: AnyIpcCall) => [newKey: string, newValue: T]): R { return Object.fromEntries( Object.entries(ipc).map(([groupKey, group]) => { const newGroup = Object.fromEntries( diff --git a/desktop/packages/mullvad-vpn/src/shared/ipc-schema.ts b/desktop/packages/mullvad-vpn/src/shared/ipc-schema.ts index 48148d9498..9a9580fc13 100644 --- a/desktop/packages/mullvad-vpn/src/shared/ipc-schema.ts +++ b/desktop/packages/mullvad-vpn/src/shared/ipc-schema.ts @@ -82,6 +82,8 @@ export interface IAppStateSnapshot { isMacOs13OrNewer: boolean; } +export type IpcSchema = typeof ipcSchema; + // The different types of requests are: // * send<ArgumentType>(), which is used for one-way communication from the renderer process to the // main process. The main channel will have a property named 'handle<PropertyName>' and the diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts new file mode 100644 index 0000000000..46f2dcd6a0 --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/login.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from '@playwright/test'; +import { Page } from 'playwright'; + +import { DeviceEvent } from '../../../src/shared/daemon-rpc-types'; +import { RoutesObjectModel } from '../route-object-models'; +import { MockedTestUtils, startMockedApp } from './mocked-utils'; + +let page: Page; +let util: MockedTestUtils; +let routes: RoutesObjectModel; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('Clear account history warnings', () => { + const startup = async () => { + ({ page, util } = await startMockedApp()); + routes = new RoutesObjectModel(page, util); + }; + + const logout = async () => { + await util.sendMockIpcResponse<DeviceEvent>({ + channel: util.ipcEvents.account.device, + response: { type: 'logged out', deviceState: { type: 'logged out' } }, + }); + + await routes.login.waitForRoute(); + }; + + test.beforeAll(async () => { + await startup(); + await routes.main.waitForRoute(); + }); + + test.beforeEach(async () => { + await logout(); + }); + + test.afterAll(async () => { + await page.close(); + }); + + const setAccountHistory = async () => { + await util.sendMockIpcResponse({ + channel: util.ipcEvents.accountHistory[''], + response: '1234123412341234', + }); + }; + + test('Should not warn about creating an account', async () => { + const accountHistoryItemButton = routes.login.getAccountHistoryItemButton(); + await expect(accountHistoryItemButton).not.toBeVisible(); + + await Promise.all([ + util.expectIpcCall(util.ipcEvents.account.create), + routes.login.createNewAccount(), + ]); + }); + + test('Should warn about creating an account', async () => { + await setAccountHistory(); + + const confirmationMessage = routes.login.getCreateNewAccountConfirmationMessage(); + await expect(confirmationMessage).not.toBeVisible(); + await routes.login.createNewAccount(); + await expect(confirmationMessage).toBeVisible(); + await routes.login.cancelCreateNewAccount(); + await expect(confirmationMessage).not.toBeVisible(); + + await routes.login.createNewAccount(); + + await Promise.all([ + util.expectIpcCall(util.ipcEvents.account.create), + routes.login.confirmCreateNewAccount(), + ]); + }); + + test('Should warn about clearing account history', async () => { + await setAccountHistory(); + + const accountHistoryItemButton = routes.login.getAccountHistoryItemButton(); + await expect(accountHistoryItemButton).toBeVisible(); + + const confirmationMessage = routes.login.getClearAccountHistoryConfirmationMessage(); + await expect(confirmationMessage).not.toBeVisible(); + await routes.login.clearAccountHistory(); + await expect(confirmationMessage).toBeVisible(); + await routes.login.cancelClearAccountHistory(); + await expect(confirmationMessage).not.toBeVisible(); + await expect(accountHistoryItemButton).toBeVisible(); + + await routes.login.clearAccountHistory(); + await Promise.all([ + util.expectIpcCall(util.ipcEvents.accountHistory.clear), + routes.login.confirmClearAccountHistory(), + ]); + + await util.sendMockIpcResponse({ + channel: util.ipcEvents.accountHistory[''], + response: undefined, + }); + await expect(accountHistoryItemButton).not.toBeVisible(); + }); +}); diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/mocked-utils.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/mocked-utils.ts index 171cc1cc50..0290536418 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/mocked-utils.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/mocked-utils.ts @@ -1,5 +1,7 @@ import { ElectronApplication } from 'playwright'; +import { createIpcEvents, IpcEvents } from '../../../src/shared/ipc-helpers'; +import { IpcSchema, ipcSchema } from '../../../src/shared/ipc-schema'; import { startApp, TestUtils } from '../utils'; // This option can be removed in the future when/if we're able to tun the tests with the sandbox @@ -13,6 +15,8 @@ interface StartMockedAppResponse extends Awaited<ReturnType<typeof startApp>> { export interface MockedTestUtils extends TestUtils { mockIpcHandle: MockIpcHandle; sendMockIpcResponse: SendMockIpcResponse; + expectIpcCall: ExpectIpcCall; + ipcEvents: IpcEvents<IpcSchema>; } export const startMockedApp = async (): Promise<StartMockedAppResponse> => { @@ -27,6 +31,7 @@ export const startMockedApp = async (): Promise<StartMockedAppResponse> => { const startAppResult = await startApp({ args }); const mockIpcHandle = generateMockIpcHandle(startAppResult.app); const sendMockIpcResponse = generateSendMockIpcResponse(startAppResult.app); + const expectIpcCall = generateExpectIpcCall(startAppResult.app); return { ...startAppResult, @@ -34,6 +39,9 @@ export const startMockedApp = async (): Promise<StartMockedAppResponse> => { ...startAppResult.util, mockIpcHandle, sendMockIpcResponse, + expectIpcCall, + + ipcEvents: createIpcEvents(ipcSchema), }, }; }; @@ -83,3 +91,20 @@ export const generateSendMockIpcResponse = (electronApp: ElectronApplication) => ); }; }; + +export type ExpectIpcCall = ReturnType<typeof generateExpectIpcCall>; + +export const generateExpectIpcCall = (electronApp: ElectronApplication) => { + return <T>(channel: string): Promise<T> => { + return electronApp.evaluate( + ({ ipcMain }, { channel }) => { + return new Promise<T>((resolve) => { + ipcMain.handleOnce(channel, (_event, arg) => { + resolve(arg); + }); + }); + }, + { channel }, + ); + }; +}; diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/index.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/index.ts new file mode 100644 index 0000000000..2bee8b20be --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/index.ts @@ -0,0 +1,2 @@ +export * from './login-route-object-model'; +export * from './selectors'; diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/login-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/login-route-object-model.ts new file mode 100644 index 0000000000..4dfe73a2a8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/login-route-object-model.ts @@ -0,0 +1,56 @@ +import { Page } from 'playwright'; + +import { RoutePath } from '../../../../src/shared/routes'; +import { TestUtils } from '../../utils'; +import { createSelectors } from './selectors'; + +export class LoginRouteObjectModel { + readonly selectors: ReturnType<typeof createSelectors>; + + constructor( + private readonly page: Page, + private readonly utils: TestUtils, + ) { + this.selectors = createSelectors(this.page); + } + + async waitForRoute() { + await this.utils.waitForRoute(RoutePath.login); + } + + async createNewAccount() { + await this.selectors.createNewAccountButton().click(); + } + + getCreateNewAccountConfirmationMessage() { + return this.selectors.createNewAccountMessage(); + } + + async confirmCreateNewAccount() { + await this.selectors.confirmCreateNewAccountButton().click(); + } + + async cancelCreateNewAccount() { + await this.selectors.cancelDialogButton().click(); + } + + async clearAccountHistory() { + await this.selectors.clearAccountHistory().click(); + } + + getAccountHistoryItemButton() { + return this.selectors.accountHistoryItemButton(); + } + + getClearAccountHistoryConfirmationMessage() { + return this.selectors.clearAccountHistoryMessage(); + } + + async confirmClearAccountHistory() { + await this.selectors.confirmClearAccountHistoryButton().click(); + } + + async cancelClearAccountHistory() { + await this.selectors.cancelDialogButton().click(); + } +} diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/selectors.ts new file mode 100644 index 0000000000..c49df8ce87 --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/login/selectors.ts @@ -0,0 +1,15 @@ +import { Page } from 'playwright'; + +export const createSelectors = (page: Page) => ({ + createNewAccountButton: () => page.getByRole('button', { name: 'Create account' }), + createNewAccountMessage: () => page.getByText('Do you want to create a new account?'), + confirmCreateNewAccountButton: () => page.getByRole('button', { name: 'Create new account' }), + + accountHistoryItemButton: () => page.getByRole('button', { name: 'Login with account number' }), + clearAccountHistory: () => page.getByRole('button', { name: 'Forget account number' }), + clearAccountHistoryMessage: () => + page.getByText('Do you want to remove the saved account number?'), + confirmClearAccountHistoryButton: () => page.getByRole('button', { name: 'Remove' }), + + cancelDialogButton: () => page.getByRole('button', { name: 'Cancel' }), +}); 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 2de9fa7d64..ec58260dd3 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 @@ -4,6 +4,7 @@ import { TestUtils } from '../utils'; import { DaitaSettingsRouteObjectModel } from './daita-settings'; import { FilterRouteObjectModel } from './filter'; import { LaunchRouteObjectModel } from './launch'; +import { LoginRouteObjectModel } from './login'; import { MainRouteObjectModel } from './main'; import { MultihopSettingsRouteObjectModel } from './multihop-settings'; import { SelectLanguageRouteObjectModel } from './select-language'; @@ -17,6 +18,7 @@ import { WireguardSettingsRouteObjectModel } from './wireguard-settings'; export class RoutesObjectModel { readonly main: MainRouteObjectModel; readonly launch: LaunchRouteObjectModel; + readonly login: LoginRouteObjectModel; readonly settings: SettingsRouteObjectModel; readonly userInterfaceSettings: UserInterfaceSettingsRouteObjectModel; readonly selectLanguage: SelectLanguageRouteObjectModel; @@ -32,6 +34,7 @@ export class RoutesObjectModel { this.selectLanguage = new SelectLanguageRouteObjectModel(page, utils); this.main = new MainRouteObjectModel(page, utils); this.launch = new LaunchRouteObjectModel(page, utils); + this.login = new LoginRouteObjectModel(page, utils); this.settings = new SettingsRouteObjectModel(page, utils); this.userInterfaceSettings = new UserInterfaceSettingsRouteObjectModel(page, utils); this.filter = new FilterRouteObjectModel(page, utils); |
