diff options
| author | Markus Pettersson <markus.pettersson@mullvad.net> | 2025-06-05 13:16:42 +0200 |
|---|---|---|
| committer | Markus Pettersson <markus.pettersson@mullvad.net> | 2025-06-05 13:16:42 +0200 |
| commit | 6f05ce41a5cf9edd5b8e3a734bcf2eb89b53a44a (patch) | |
| tree | de8504e9d33179dd18d9409ca1e93d2000caeb56 | |
| parent | fe325b16c5fb14235d4f8f76d59c1815add1fc67 (diff) | |
| parent | 2eb23455ebe8b169ae0dc8a8b7b3d36bf9fbb585 (diff) | |
| download | mullvadvpn-6f05ce41a5cf9edd5b8e3a734bcf2eb89b53a44a.tar.xz mullvadvpn-6f05ce41a5cf9edd5b8e3a734bcf2eb89b53a44a.zip | |
Merge branch 'automate-relay-selector-gui-tests-des-2029'
22 files changed, 804 insertions, 186 deletions
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/Filter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/Filter.tsx index 12c0d40ad4..f461354268 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/Filter.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/Filter.tsx @@ -13,15 +13,14 @@ import { import { colors } from '../lib/foundations'; import { useHistory } from '../lib/history'; import { useNormalRelaySettings, useTunnelProtocol } from '../lib/relay-settings-hooks'; -import { useBoolean } from '../lib/utility-hooks'; import { IRelayLocationCountryRedux } from '../redux/settings/reducers'; import { useSelector } from '../redux/store'; import { AppNavigationHeader } from './'; -import Accordion from './Accordion'; -import { AriaInputGroup, AriaLabel } from './AriaGroup'; +import { AriaInputGroup } from './AriaGroup'; import * as Cell from './cell'; import Selector from './cell/Selector'; import { normalText } from './common-styles'; +import { FilterAccordion } from './FilterAccordion'; import { BackAction } from './KeyboardNavigation'; import { Footer, Layout, SettingsContainer } from './Layout'; import { NavigationContainer } from './NavigationContainer'; @@ -207,8 +206,6 @@ interface IFilterByOwnershipProps { } function FilterByOwnership(props: IFilterByOwnershipProps) { - const [expanded, , , toggleExpanded] = useBoolean(false); - const values = useMemo( () => [ @@ -226,14 +223,7 @@ function FilterByOwnership(props: IFilterByOwnershipProps) { return ( <AriaInputGroup> - <Cell.CellButton onClick={toggleExpanded}> - <AriaLabel> - <Cell.Label>{messages.pgettext('filter-view', 'Ownership')}</Cell.Label> - </AriaLabel> - <Icon color="whiteAlpha80" icon={expanded ? 'chevron-up' : 'chevron-down'} /> - </Cell.CellButton> - - <Accordion expanded={expanded}> + <FilterAccordion title={messages.pgettext('filter-view', 'Ownership')}> <StyledSelector items={values} value={props.ownership} @@ -241,7 +231,7 @@ function FilterByOwnership(props: IFilterByOwnershipProps) { automaticLabel={messages.gettext('Any')} automaticValue={Ownership.any} /> - </Accordion> + </FilterAccordion> </AriaInputGroup> ); } @@ -255,8 +245,6 @@ interface IFilterByProviderProps { function FilterByProvider(props: IFilterByProviderProps) { const { setProviders } = props; - const [expanded, , , toggleExpanded] = useBoolean(false); - const onToggle = useCallback( (provider: string) => setProviders((providers) => { @@ -273,25 +261,19 @@ function FilterByProvider(props: IFilterByProviderProps) { }, [setProviders]); return ( - <> - <Cell.CellButton onClick={toggleExpanded}> - <Cell.Label>{messages.pgettext('filter-view', 'Providers')}</Cell.Label> - <Icon color="whiteAlpha80" icon={expanded ? 'chevron-up' : 'chevron-down'} /> - </Cell.CellButton> - <Accordion expanded={expanded}> - <CheckboxRow - label={messages.pgettext('filter-view', 'All providers')} - $bold - checked={Object.values(props.providers).every((value) => value)} - onChange={toggleAll} - /> - {Object.entries(props.providers) - .filter(([provider]) => props.availableOptions.includes(provider)) - .map(([provider, checked]) => ( - <CheckboxRow key={provider} label={provider} checked={checked} onChange={onToggle} /> - ))} - </Accordion> - </> + <FilterAccordion title={messages.pgettext('filter-view', 'Providers')}> + <CheckboxRow + label={messages.pgettext('filter-view', 'All providers')} + $bold + checked={Object.values(props.providers).every((value) => value)} + onChange={toggleAll} + /> + {Object.entries(props.providers) + .filter(([provider]) => props.availableOptions.includes(provider)) + .map(([provider, checked]) => ( + <CheckboxRow key={provider} label={provider} checked={checked} onChange={onToggle} /> + ))} + </FilterAccordion> ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/FilterAccordion.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/FilterAccordion.tsx new file mode 100644 index 0000000000..1c5c7f5230 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/FilterAccordion.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { Accordion } from '../lib/components/accordion'; + +export type FilterAccordionProps = { + title?: string; + children?: React.ReactNode; + defaultOpen?: boolean; +}; + +export function FilterAccordion({ title, children, defaultOpen }: FilterAccordionProps) { + const [open, setOpen] = React.useState(defaultOpen); + return ( + <Accordion expanded={open} onExpandedChange={setOpen}> + <Accordion.Trigger> + <Accordion.Header> + <Accordion.Title>{title}</Accordion.Title> + <Accordion.Icon /> + </Accordion.Header> + </Accordion.Trigger> + <Accordion.Content>{children}</Accordion.Content> + </Accordion> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/Accordion.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/Accordion.tsx new file mode 100644 index 0000000000..2ed9043972 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/Accordion.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import styled from 'styled-components'; + +import { AccordionProvider } from './AccordionContext'; +import { AccordionHeader, AccordionTrigger } from './components'; +import { AccordionContent } from './components/AccordionContent'; +import { AccordionIcon } from './components/AccordionIcon'; +import { AccordionTitle } from './components/AccordionTitle'; + +export type AccordionProps = { + expanded?: boolean; + onExpandedChange?: (open: boolean) => void; + children?: React.ReactNode; +}; + +const StyledAccordion = styled.div` + display: flex; + flex: 1; + flex-direction: column; + width: 100%; +`; + +function Accordion({ expanded = false, onExpandedChange: onOpenChange, children }: AccordionProps) { + const triggerId = React.useId(); + const contentId = React.useId(); + return ( + <AccordionProvider + triggerId={triggerId} + contentId={contentId} + expanded={expanded} + onExpandedChange={onOpenChange}> + <StyledAccordion>{children}</StyledAccordion> + </AccordionProvider> + ); +} + +const AccordionNamespace = Object.assign(Accordion, { + Trigger: AccordionTrigger, + Header: AccordionHeader, + Content: AccordionContent, + Title: AccordionTitle, + Icon: AccordionIcon, +}); + +export { AccordionNamespace as Accordion }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/AccordionContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/AccordionContext.tsx new file mode 100644 index 0000000000..e39295f1a8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/AccordionContext.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import { AccordionProps } from './Accordion'; + +interface AccordionContextProps { + triggerId: string; + contentId: string; + expanded: AccordionProps['expanded']; + onExpandedChange?: AccordionProps['onExpandedChange']; +} + +const AccordionContext = React.createContext<AccordionContextProps | undefined>(undefined); + +export const useAccordionContext = (): AccordionContextProps => { + const context = React.useContext(AccordionContext); + if (!context) { + throw new Error('useAccordionContext must be used within a AccordionProvider'); + } + return context; +}; + +interface AccordionProviderProps { + triggerId: string; + contentId: string; + expanded: boolean; + onExpandedChange?: (open: boolean) => void; + children: React.ReactNode; +} + +export function AccordionProvider({ + triggerId, + contentId, + expanded, + onExpandedChange, + children, +}: AccordionProviderProps) { + return ( + <AccordionContext.Provider value={{ triggerId, contentId, expanded, onExpandedChange }}> + {children} + </AccordionContext.Provider> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionContent.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionContent.tsx new file mode 100644 index 0000000000..beeb3ff238 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionContent.tsx @@ -0,0 +1,26 @@ +import styled from 'styled-components'; + +import { Animate } from '../../animate'; +import { useAccordionContext } from '../AccordionContext'; + +export type AccordionContentProps = { + children?: React.ReactNode; +}; + +const StyledAccordionContent = styled.div` + width: 100%; +`; + +export function AccordionContent({ children }: AccordionContentProps) { + const { contentId, triggerId, expanded } = useAccordionContext(); + return ( + <Animate + present={expanded} + animations={[{ type: 'wipe', direction: 'vertical' }]} + duration="0.35s"> + <StyledAccordionContent id={contentId} aria-labelledby={triggerId} role="region"> + {children} + </StyledAccordionContent> + </Animate> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionHeader.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionHeader.tsx new file mode 100644 index 0000000000..b71effcfba --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionHeader.tsx @@ -0,0 +1,30 @@ +import styled from 'styled-components'; + +import { colors } from '../../../foundations'; +import { Flex } from '../../flex'; +import { StyledAccordionIcon } from './AccordionIcon'; + +export type AccordionHeaderProps = { + children?: React.ReactNode; +}; + +export const StyledAccordionHeader = styled(Flex)` + background-color: ${colors.blue}; + width: 100%; + min-height: 48px; + margin-bottom: 1px; + + && > ${StyledAccordionIcon} { + margin-left: auto; + } +`; + +export function AccordionHeader({ children }: AccordionHeaderProps) { + return ( + <StyledAccordionHeader + $padding={{ horizontal: 'medium', vertical: 'small' }} + $alignItems="center"> + {children} + </StyledAccordionHeader> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionIcon.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionIcon.tsx new file mode 100644 index 0000000000..043a72fa00 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionIcon.tsx @@ -0,0 +1,18 @@ +import styled from 'styled-components'; + +import { Icon, IconProps } from '../../icon'; +import { useAccordionContext } from '../AccordionContext'; + +export type AccordionIconProps = Omit<IconProps, 'icon'> & { + icon?: IconProps['icon']; +}; + +export const StyledAccordionIcon = styled(Icon)` + flex-shrink: 0; +`; + +export function AccordionIcon({ icon, color = 'whiteAlpha80', ...props }: AccordionIconProps) { + const { expanded: open } = useAccordionContext(); + const iconName = icon || (open ? 'chevron-up' : 'chevron-down'); + return <StyledAccordionIcon icon={iconName} color={color} {...props} />; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTitle.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTitle.tsx new file mode 100644 index 0000000000..e9d287b261 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTitle.tsx @@ -0,0 +1,17 @@ +import styled from 'styled-components'; + +import { Text } from '../../typography'; + +export type AccordionTitleProps = { + children?: React.ReactNode; +}; + +export const StyledTitleLabel = styled(Text)``; + +export function AccordionTitle({ children }: AccordionTitleProps) { + return ( + <StyledTitleLabel $padding="medium" color="white" variant="titleMedium"> + {children} + </StyledTitleLabel> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTrigger.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTrigger.tsx new file mode 100644 index 0000000000..30ba2092c0 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTrigger.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import styled from 'styled-components'; + +import { colors } from '../../../foundations'; +import { ButtonBase } from '../../button'; +import { useAccordionContext } from '../AccordionContext'; +import { StyledAccordionHeader } from './AccordionHeader'; + +export type AccordionTriggerProps = { + children?: React.ReactNode; +}; + +const StyledAccordionTrigger = styled(ButtonBase)` + background-color: transparent; + &&:hover > ${StyledAccordionHeader} { + background-color: ${colors.blue60}; + } + &&:active > ${StyledAccordionHeader} { + background-color: ${colors.blue40}; + } + &&:focus-visible { + outline: 2px solid ${colors.white}; + outline-offset: -2px; + } +`; + +export function AccordionTrigger({ children }: AccordionTriggerProps) { + const { contentId, triggerId, expanded, onExpandedChange } = useAccordionContext(); + + const onClick = React.useCallback( + (e: React.MouseEvent<HTMLButtonElement>) => { + e.preventDefault(); + onExpandedChange?.(!expanded); + }, + [onExpandedChange, expanded], + ); + + return ( + <StyledAccordionTrigger + id={triggerId} + aria-controls={contentId} + aria-expanded={expanded ? 'true' : 'false'} + onClick={onClick}> + {children} + </StyledAccordionTrigger> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/index.ts new file mode 100644 index 0000000000..abd6b8865b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/index.ts @@ -0,0 +1,2 @@ +export * from './AccordionHeader'; +export * from './AccordionTrigger'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/index.ts new file mode 100644 index 0000000000..63f62bc659 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/index.ts @@ -0,0 +1 @@ +export * from './Accordion'; diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location.spec.ts deleted file mode 100644 index 624142fbf9..0000000000 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location.spec.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { Page } from 'playwright'; - -import { getDefaultSettings } from '../../../src/main/default-settings'; -import { colorTokens } from '../../../src/renderer/lib/foundations'; -import { - IRelayList, - IRelayListWithEndpointData, - ISettings, - IWireguardEndpointData, -} from '../../../src/shared/daemon-rpc-types'; -import { RoutePath } from '../../../src/shared/routes'; -import { MockedTestUtils, startMockedApp } from './mocked-utils'; - -const relayList: IRelayList = { - countries: [ - { - name: 'Sweden', - code: 'se', - cities: [ - { - name: 'Gothenburg', - code: 'got', - latitude: 58, - longitude: 12, - relays: [ - { - hostname: 'se-got-wg-101', - provider: 'mullvad', - ipv4AddrIn: '10.0.0.1', - includeInCountry: true, - active: true, - weight: 0, - owned: true, - endpointType: 'wireguard', - daita: true, - }, - { - hostname: 'se-got-wg-102', - provider: 'mullvad', - ipv4AddrIn: '10.0.0.2', - includeInCountry: true, - active: true, - weight: 0, - owned: true, - endpointType: 'wireguard', - daita: true, - }, - ], - }, - ], - }, - ], -}; - -const wireguardEndpointData: IWireguardEndpointData = { - portRanges: [], - udp2tcpPorts: [], -}; - -let page: Page; -let util: MockedTestUtils; - -test.beforeAll(async () => { - ({ page, util } = await startMockedApp()); - await util.waitForRoute(RoutePath.main); - await setMultihop(); - await page.getByLabel('Select location').click(); - await util.waitForRoute(RoutePath.selectLocation); -}); - -test.afterAll(async () => { - await page.close(); -}); - -async function setMultihop() { - const settings = getDefaultSettings(); - if ('normal' in settings.relaySettings) { - settings.relaySettings.normal.wireguardConstraints.useMultihop = true; - } - - await util.sendMockIpcResponse<ISettings>({ - channel: 'settings-', - response: settings, - }); - - await util.sendMockIpcResponse<IRelayListWithEndpointData>({ - channel: 'relays-', - response: { relayList, wireguardEndpointData }, - }); -} - -test('App should show entry selection', async () => { - const entryTab = page.getByText('Entry'); - await entryTab.click(); - await expect(entryTab).toHaveCSS('background-color', colorTokens.green); - - const sweden = page.getByText('Sweden'); - await expect(sweden).toBeVisible(); -}); - -test('App should show exit selection', async () => { - const exitTab = page.getByText('Exit'); - await exitTab.click(); - await expect(exitTab).toHaveCSS('background-color', colorTokens.green); - - const sweden = page.getByText('Sweden'); - await expect(sweden).toBeVisible(); -}); - -test("App shouldn't show entry selection when daita is enabled without direct only", async () => { - const settings = getDefaultSettings(); - if ('normal' in settings.relaySettings && settings.tunnelOptions.wireguard.daita) { - settings.relaySettings.normal.wireguardConstraints.useMultihop = true; - settings.tunnelOptions.wireguard.daita.enabled = true; - settings.tunnelOptions.wireguard.daita.directOnly = false; - } - - await util.sendMockIpcResponse<ISettings>({ - channel: 'settings-', - response: settings, - }); - - const entryTab = page.getByText('Entry').first(); - await entryTab.click(); - await expect(entryTab).toHaveCSS('background-color', colorTokens.green); - - const sweden = page.getByText('Sweden'); - await expect(sweden).not.toBeVisible(); -}); - -test('App should show entry selection when daita is enabled with direct only', async () => { - const settings = getDefaultSettings(); - if ('normal' in settings.relaySettings && settings.tunnelOptions.wireguard.daita) { - settings.relaySettings.normal.wireguardConstraints.useMultihop = true; - settings.tunnelOptions.wireguard.daita.enabled = true; - settings.tunnelOptions.wireguard.daita.directOnly = true; - } - - await util.sendMockIpcResponse<ISettings>({ - channel: 'settings-', - response: settings, - }); - - const entryTab = page.getByText('Entry'); - await entryTab.click(); - await expect(entryTab).toHaveCSS('background-color', colorTokens.green); - - const sweden = page.getByText('Sweden'); - await expect(sweden).toBeVisible(); -}); diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/helpers.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/helpers.ts new file mode 100644 index 0000000000..7490adc6ac --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/helpers.ts @@ -0,0 +1,114 @@ +import { Page } from 'playwright'; + +import { getDefaultSettings } from '../../../../src/main/default-settings'; +import { + IRelayList, + IRelayListCity, + IRelayListCountry, + IRelayListHostname, + Ownership, +} from '../../../../src/shared/daemon-rpc-types'; +import { RoutePath } from '../../../../src/shared/routes'; +import { RoutesObjectModel } from '../../route-object-models'; +import { MockedTestUtils } from '../mocked-utils'; + +export type LocatedRelay = { + country: IRelayListCountry; + city: IRelayListCity; + relay: IRelayListHostname; +}; + +export const createHelpers = (page: Page, routes: RoutesObjectModel, utils: MockedTestUtils) => { + const areAllCheckboxesChecked = async () => { + const checkboxes = page.getByRole('checkbox'); + return checkboxes.evaluateAll((checkboxes) => + checkboxes.every((checkbox) => checkbox.getAttribute('aria-checked') === 'true'), + ); + }; + + const expandLocatedRelays = async (locatedRelays: LocatedRelay[]) => { + for (const locatedRelay of locatedRelays) { + await routes.selectLocation.toggleAccordion(locatedRelay.country.name); + await routes.selectLocation.toggleAccordion(locatedRelay.city.name); + } + }; + + const locateRelaysByProvider = (relayList: IRelayList, provider?: string): LocatedRelay[] => + relayList.countries.flatMap((country) => + country.cities.flatMap((city) => + city.relays + .filter((relay) => !provider || relay.provider === provider) + .map((relay) => ({ country, city, relay })), + ), + ); + + const locateRelaysByOwner = (relayList: IRelayList, owned?: boolean): LocatedRelay[] => + relayList.countries.flatMap((country) => + country.cities.flatMap((city) => + city.relays + .filter((relay) => relay.owned === owned) + .map((relay) => ({ country, city, relay })), + ), + ); + + const resetOwnership = async () => { + await routes.filter.expandOwnership(); + await routes.filter.selectOwnershipOption('Any'); + await routes.filter.collapseOwnership(); + }; + + const resetProviders = async () => { + await routes.filter.expandProviders(); + const allCheckboxesChecked = await areAllCheckboxesChecked(); + if (!allCheckboxesChecked) { + await routes.filter.checkAllProvidersCheckbox(); + } + await routes.filter.collapseProviders(); + }; + + const resetView = async () => { + const currentRoute = await utils.currentRoute(); + if (currentRoute === RoutePath.selectLocation) { + await routes.selectLocation.gotoFilter(); + } + }; + + const updateMockRelayFilter = async ({ + ownership, + providers, + }: { + ownership?: Ownership; + providers?: string[]; + }) => { + const settings = getDefaultSettings(); + if ('normal' in settings.relaySettings) { + if (ownership) { + settings.relaySettings.normal.ownership = ownership; + } + if (providers) { + settings.relaySettings.normal.providers = providers; + } + } + await utils.mockIpcHandle({ + channel: 'settings-setRelaySettings', + response: {}, + }); + await utils.sendMockIpcResponse({ + channel: 'settings-', + response: settings, + }); + }; + + return { + areAllCheckboxesChecked, + expandLocatedRelays, + locateRelaysByProvider, + locateRelaysByOwner, + resetOwnership, + resetProviders, + resetView, + updateMockRelayFilter, + }; +}; + +export type SelectLocationHelpers = ReturnType<typeof createHelpers>; diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/mock-data.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/mock-data.ts new file mode 100644 index 0000000000..6e60e29c8d --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/mock-data.ts @@ -0,0 +1,57 @@ +import { IRelayList } from '../../../../src/shared/daemon-rpc-types'; + +const relayList: IRelayList = { + countries: [ + { + name: 'Sweden', + code: 'se', + cities: [ + { + name: 'Gothenburg', + code: 'got', + latitude: 58, + longitude: 12, + relays: [ + { + hostname: 'my-test-relay1', + provider: 'mullvad', + ipv4AddrIn: '10.0.0.1', + includeInCountry: true, + active: true, + weight: 0, + owned: true, + endpointType: 'wireguard', + daita: true, + }, + { + hostname: 'my-test-relay2', + provider: 'mullvad', + ipv4AddrIn: '10.0.0.2', + includeInCountry: true, + active: true, + weight: 0, + owned: true, + endpointType: 'wireguard', + daita: true, + }, + { + hostname: 'se-got-wg-103', + provider: 'another-provider', + ipv4AddrIn: '10.0.0.3', + includeInCountry: true, + active: true, + weight: 0, + owned: false, + endpointType: 'wireguard', + daita: true, + }, + ], + }, + ], + }, + ], +}; + +export const mockData = { + relayList, +}; diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/select-location.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/select-location.spec.ts new file mode 100644 index 0000000000..b01c5ac054 --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/select-location.spec.ts @@ -0,0 +1,220 @@ +import { expect, test } from '@playwright/test'; +import { Page } from 'playwright'; + +import { getDefaultSettings } from '../../../../src/main/default-settings'; +import { colorTokens } from '../../../../src/renderer/lib/foundations'; +import { + IRelayListWithEndpointData, + ISettings, + IWireguardEndpointData, + Ownership, +} from '../../../../src/shared/daemon-rpc-types'; +import { RoutePath } from '../../../../src/shared/routes'; +import { RoutesObjectModel } from '../../route-object-models'; +import { MockedTestUtils, startMockedApp } from '../mocked-utils'; +import { createHelpers, SelectLocationHelpers } from './helpers'; +import { mockData } from './mock-data'; + +const wireguardEndpointData: IWireguardEndpointData = { + portRanges: [], + udp2tcpPorts: [], +}; + +let page: Page; +let util: MockedTestUtils; +let routes: RoutesObjectModel; +let helpers: SelectLocationHelpers; +const { relayList } = mockData; + +test.describe('Select location', () => { + test.beforeAll(async () => { + ({ page, util } = await startMockedApp()); + routes = new RoutesObjectModel(page, util); + helpers = createHelpers(page, routes, util); + await util.waitForRoute(RoutePath.main); + await page.getByLabel('Select location').click(); + await util.waitForRoute(RoutePath.selectLocation); + + await util.sendMockIpcResponse<IRelayListWithEndpointData>({ + channel: 'relays-', + response: { relayList, wireguardEndpointData }, + }); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.describe('Multihop enabled', () => { + test.beforeAll(async () => { + const settings = getDefaultSettings(); + if ('normal' in settings.relaySettings) { + settings.relaySettings.normal.wireguardConstraints.useMultihop = true; + } + + await util.sendMockIpcResponse<ISettings>({ + channel: 'settings-', + response: settings, + }); + }); + + test('App should show entry selection', async () => { + const entryTab = page.getByText('Entry'); + await entryTab.click(); + await expect(entryTab).toHaveCSS('background-color', colorTokens.green); + + const sweden = page.getByText('Sweden'); + await expect(sweden).toBeVisible(); + }); + + test('App should show exit selection', async () => { + const exitTab = page.getByText('Exit'); + await exitTab.click(); + await expect(exitTab).toHaveCSS('background-color', colorTokens.green); + + const sweden = page.getByText('Sweden'); + await expect(sweden).toBeVisible(); + }); + + test("App shouldn't show entry selection when daita is enabled without direct only", async () => { + const settings = getDefaultSettings(); + if ('normal' in settings.relaySettings && settings.tunnelOptions.wireguard.daita) { + settings.relaySettings.normal.wireguardConstraints.useMultihop = true; + settings.tunnelOptions.wireguard.daita.enabled = true; + settings.tunnelOptions.wireguard.daita.directOnly = false; + } + + await util.sendMockIpcResponse<ISettings>({ + channel: 'settings-', + response: settings, + }); + + const entryTab = page.getByText('Entry').first(); + await entryTab.click(); + await expect(entryTab).toHaveCSS('background-color', colorTokens.green); + + const sweden = page.getByText('Sweden'); + await expect(sweden).not.toBeVisible(); + }); + + test('App should show entry selection when daita is enabled with direct only', async () => { + const settings = getDefaultSettings(); + if ('normal' in settings.relaySettings && settings.tunnelOptions.wireguard.daita) { + settings.relaySettings.normal.wireguardConstraints.useMultihop = true; + settings.tunnelOptions.wireguard.daita.enabled = true; + settings.tunnelOptions.wireguard.daita.directOnly = true; + } + + await util.sendMockIpcResponse<ISettings>({ + channel: 'settings-', + response: settings, + }); + + const entryTab = page.getByText('Entry'); + await entryTab.click(); + await expect(entryTab).toHaveCSS('background-color', colorTokens.green); + + const sweden = page.getByText('Sweden'); + await expect(sweden).toBeVisible(); + }); + }); + + test.describe('Filter', () => { + test.beforeEach(async () => { + await helpers.resetView(); + await helpers.resetProviders(); + await helpers.resetOwnership(); + }); + + test.describe('Filter by provider', () => { + test('Should deselect all providers when clicking all providers checkbox', async () => { + await routes.filter.expandProviders(); + await routes.filter.checkAllProvidersCheckbox(); + expect(await helpers.areAllCheckboxesChecked()).toBe(false); + + await routes.filter.checkAllProvidersCheckbox(); + expect(await helpers.areAllCheckboxesChecked()).toBe(true); + }); + + test('Should apply filter when selecting provider', async () => { + await routes.filter.expandProviders(); + await routes.filter.checkAllProvidersCheckbox(); + expect(await helpers.areAllCheckboxesChecked()).toBe(false); + + // Select one provider + const provider = relayList.countries[0].cities[0].relays[0].provider; + await routes.filter.checkProviderCheckbox(provider); + + await helpers.updateMockRelayFilter({ + providers: [provider], + }); + + await routes.filter.applyFilter(); + await util.waitForRoute(RoutePath.selectLocation); + const providerFilterChip = routes.selectLocation.getFilterChip('Providers: 1'); + await expect(providerFilterChip).toBeVisible(); + + const locatedRelays = helpers.locateRelaysByProvider(relayList, provider); + const relays = locatedRelays.map((locatedRelay) => locatedRelay.relay); + const relayNames = relays.map((relay) => relay.hostname); + + // Expand all accordions + await helpers.expandLocatedRelays(locatedRelays); + + const buttons = routes.selectLocation.getRelaysMatching(relayNames); + + // Expect all filtered relays to have a button + await expect(buttons).toHaveCount(relays.length); + + // Clear filter + await providerFilterChip.click(); + + // Get all relays and expand accordions + const allLocatedRelays = helpers.locateRelaysByProvider(relayList); + await helpers.expandLocatedRelays(allLocatedRelays); + + // Should not have same length as all relays + await expect(buttons).not.toHaveCount(allLocatedRelays.length); + }); + }); + + test.describe('Filter by ownership', () => { + test('Should apply filter when selecting ownership', async () => { + // Select rented only + await routes.filter.expandOwnership(); + await routes.filter.selectOwnershipOption('Rented only'); + await helpers.updateMockRelayFilter({ + ownership: Ownership.rented, + }); + + await routes.filter.applyFilter(); + await util.waitForRoute(RoutePath.selectLocation); + + const ownerFilterChip = routes.selectLocation.getFilterChip('Rented'); + await expect(ownerFilterChip).toBeVisible(); + + const locatedRelays = helpers.locateRelaysByOwner(relayList, false); + const relays = locatedRelays.map((locatedRelay) => locatedRelay.relay); + const relayNames = relays.map((relay) => relay.hostname); + + // Expand all accordions + await helpers.expandLocatedRelays(locatedRelays); + + const buttons = routes.selectLocation.getRelaysMatching(relayNames); + + // Expect all filtered relays to have a button + await expect(buttons).toHaveCount(relays.length); + + // Clear filter + await ownerFilterChip.click(); + + // Get all relays and expand accordions + const allLocatedRelays = helpers.locateRelaysByOwner(relayList); + await helpers.expandLocatedRelays(allLocatedRelays); + + // Should not have same length as all relays + await expect(buttons).not.toHaveCount(allLocatedRelays.length); + }); + }); + }); +}); diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/filter/filter-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/filter/filter-route-object-model.ts new file mode 100644 index 0000000000..8319c53f05 --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/filter/filter-route-object-model.ts @@ -0,0 +1,73 @@ +import { Locator, Page } from 'playwright'; + +import { RoutePath } from '../../../../src/shared/routes'; +import { TestUtils } from '../../utils'; +import { createSelectors, FilterSelectors } from './selectors'; + +export class FilterRouteObjectModel { + private readonly utils: TestUtils; + private readonly selectors: FilterSelectors; + + constructor(page: Page, util: TestUtils) { + this.utils = util; + this.selectors = createSelectors(page); + } + + async applyFilter() { + await this.selectors.applyButton().click(); + } + + async gotoSelectLocation() { + await this.selectors.backButton().click(); + await this.utils.waitForRoute(RoutePath.selectLocation); + } + + async expandProviders() { + const accordion = this.selectors.accordion('Providers'); + const expanded = await this.isExpanded(accordion); + if (!expanded) { + await accordion.click(); + await this.selectors.providersOption('All providers').waitFor({ state: 'visible' }); + } + } + + async collapseProviders() { + const accordion = this.selectors.accordion('Providers'); + const expanded = await this.isExpanded(accordion); + if (expanded) await accordion.click(); + } + + async checkAllProvidersCheckbox() { + const allProvidersCheckbox = this.selectors.providersOption('All providers'); + await allProvidersCheckbox.click(); + } + + async checkProviderCheckbox(provider: string) { + const providerCheckbox = this.selectors.providersOption(provider); + await providerCheckbox.click(); + } + + async expandOwnership() { + const accordion = this.selectors.accordion('Ownership'); + const expanded = await this.isExpanded(accordion); + if (!expanded) { + await accordion.click(); + await this.selectors.ownershipOption('Any').waitFor({ state: 'visible' }); + } + } + + async collapseOwnership() { + const accordion = this.selectors.accordion('Ownership'); + const expanded = await this.isExpanded(accordion); + if (expanded) await accordion.click(); + } + + async selectOwnershipOption(ownership: string) { + await this.selectors.ownershipOption(ownership).click(); + } + + private async isExpanded(locator: Locator): Promise<boolean> { + const ariaExpanded = await locator.getAttribute('aria-expanded'); + return ariaExpanded === 'true'; + } +} diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/filter/index.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/filter/index.ts new file mode 100644 index 0000000000..cf68b5f372 --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/filter/index.ts @@ -0,0 +1 @@ +export * from './filter-route-object-model'; diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/filter/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/filter/selectors.ts new file mode 100644 index 0000000000..984e708f07 --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/filter/selectors.ts @@ -0,0 +1,17 @@ +import { Page } from 'playwright'; + +export const createSelectors = (page: Page) => ({ + accordion: (label: string) => + page.locator('button', { + has: page.locator('div', { hasText: label }), + }), + applyButton: () => page.getByRole('button', { name: 'Apply' }), + backButton: () => page.getByRole('button', { name: 'Back' }), + ownershipOption: (label: string) => + page.locator('button', { + has: page.locator('div', { hasText: label }), + }), + providersOption: (label: string) => page.getByRole('checkbox', { name: label }), +}); + +export type FilterSelectors = ReturnType<typeof createSelectors>; 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 cb6b63f99e..eb84afec5e 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 @@ -1,8 +1,10 @@ import { Page } from 'playwright'; import { TestUtils } from '../utils'; +import { FilterRouteObjectModel } from './filter'; import { MainRouteObjectModel } from './main'; import { SelectLanguageRouteObjectModel } from './select-language'; +import { SelectLocationRouteObjectModel } from './select-location'; import { SettingsRouteObjectModel } from './settings/settings-route-object-model'; import { UserInterfaceSettingsRouteObjectModel } from './user-interface-settings'; import { VpnSettingsRouteObjectModel } from './vpn-settings'; @@ -12,6 +14,8 @@ export class RoutesObjectModel { readonly settings: SettingsRouteObjectModel; readonly userInterfaceSettings: UserInterfaceSettingsRouteObjectModel; readonly selectLanguage: SelectLanguageRouteObjectModel; + readonly filter: FilterRouteObjectModel; + readonly selectLocation: SelectLocationRouteObjectModel; readonly vpnSettings: VpnSettingsRouteObjectModel; constructor(page: Page, utils: TestUtils) { @@ -19,6 +23,8 @@ export class RoutesObjectModel { this.main = new MainRouteObjectModel(page, utils); this.settings = new SettingsRouteObjectModel(page, utils); this.userInterfaceSettings = new UserInterfaceSettingsRouteObjectModel(page, utils); + this.filter = new FilterRouteObjectModel(page, utils); + this.selectLocation = new SelectLocationRouteObjectModel(page, utils); this.vpnSettings = new VpnSettingsRouteObjectModel(page, utils); } } diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/index.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/index.ts new file mode 100644 index 0000000000..e2f2889caf --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/index.ts @@ -0,0 +1 @@ +export * from './select-location-route-object-model'; diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/select-location-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/select-location-route-object-model.ts new file mode 100644 index 0000000000..63e03b94e2 --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/select-location-route-object-model.ts @@ -0,0 +1,35 @@ +import { Page } from 'playwright'; + +import { RoutePath } from '../../../../src/shared/routes'; +import { TestUtils } from '../../utils'; +import { createSelectors } from './selectors'; + +export class SelectLocationRouteObjectModel { + private readonly utils: TestUtils; + private readonly selectors: ReturnType<typeof createSelectors>; + + constructor(page: Page, util: TestUtils) { + this.utils = util; + this.selectors = createSelectors(page); + } + + async toggleAccordion(accordionName: string) { + const expandAccordion = this.selectors.expandAccordionButton(accordionName); + if ((await expandAccordion.count()) > 0) { + await expandAccordion.click(); + } + } + + getRelaysMatching(relayNames: string[]) { + return this.selectors.relaysMatching(relayNames); + } + + getFilterChip(label: string) { + return this.selectors.filterChip(label); + } + + async gotoFilter() { + await this.selectors.filterButton().click(); + await this.utils.waitForRoute(RoutePath.filter); + } +} diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/selectors.ts new file mode 100644 index 0000000000..a2c44863ae --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/selectors.ts @@ -0,0 +1,11 @@ +import { Page } from 'playwright'; + +export const createSelectors = (page: Page) => ({ + filterButton: () => page.getByRole('button', { name: 'Filter' }), + filterChip: (label: string) => { + return page.locator('button', { hasText: label }); + }, + expandAccordionButton: (label: string) => page.getByLabel(`Expand ${label}`), + relaysMatching: (relayNames: string[]) => + page.getByRole('button', { name: new RegExp(relayNames.join('|')) }), +}); |
