summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorMarkus Pettersson <markus.pettersson@mullvad.net>2025-06-05 13:16:42 +0200
committerMarkus Pettersson <markus.pettersson@mullvad.net>2025-06-05 13:16:42 +0200
commit6f05ce41a5cf9edd5b8e3a734bcf2eb89b53a44a (patch)
treede8504e9d33179dd18d9409ca1e93d2000caeb56
parentfe325b16c5fb14235d4f8f76d59c1815add1fc67 (diff)
parent2eb23455ebe8b169ae0dc8a8b7b3d36bf9fbb585 (diff)
downloadmullvadvpn-6f05ce41a5cf9edd5b8e3a734bcf2eb89b53a44a.tar.xz
mullvadvpn-6f05ce41a5cf9edd5b8e3a734bcf2eb89b53a44a.zip
Merge branch 'automate-relay-selector-gui-tests-des-2029'
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/Filter.tsx52
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/FilterAccordion.tsx24
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/Accordion.tsx45
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/AccordionContext.tsx42
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionContent.tsx26
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionHeader.tsx30
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionIcon.tsx18
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTitle.tsx17
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTrigger.tsx47
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/select-location.spec.ts151
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/helpers.ts114
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/mock-data.ts57
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/select-location.spec.ts220
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/filter/filter-route-object-model.ts73
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/filter/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/filter/selectors.ts17
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/routes-object-model.ts6
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/select-location-route-object-model.ts35
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/selectors.ts11
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('|')) }),
+});