diff --git a/gui/src/renderer/components/ApiAccessMethods.tsx b/gui/src/renderer/components/ApiAccessMethods.tsx index 0774fcc32021..b5cffddc9438 100644 --- a/gui/src/renderer/components/ApiAccessMethods.tsx +++ b/gui/src/renderer/components/ApiAccessMethods.tsx @@ -231,7 +231,7 @@ function ApiAccessMethod(props: ApiAccessMethodProps) { }, [props.method.id, props.inUse, setApiAccessMethod, testApiAccessMethod, history.push]); return ( - + {props.method.name} {testing && ( diff --git a/gui/src/renderer/components/ContextMenu.tsx b/gui/src/renderer/components/ContextMenu.tsx index f80b456d4629..165c284ecab6 100644 --- a/gui/src/renderer/components/ContextMenu.tsx +++ b/gui/src/renderer/components/ContextMenu.tsx @@ -84,10 +84,18 @@ export function ContextMenuContainer(props: React.PropsWithChildren) { ); } +const StyledTrigger = styled.button({ + borderWidth: 0, + padding: 0, + margin: 0, + cursor: 'default', + backgroundColor: 'transparent', +}); + export function ContextMenuTrigger(props: React.PropsWithChildren) { const { toggleVisibility } = useContext(menuContext); - return
{props.children}
; + return {props.children}; } interface StyledMenuProps { diff --git a/gui/src/renderer/components/EditApiAccessMethod.tsx b/gui/src/renderer/components/EditApiAccessMethod.tsx index ecb633ae98e9..b5b3bd6d4831 100644 --- a/gui/src/renderer/components/EditApiAccessMethod.tsx +++ b/gui/src/renderer/components/EditApiAccessMethod.tsx @@ -434,7 +434,13 @@ function EditShadowsocks(props: EditMethodProps) { - + ); diff --git a/gui/src/renderer/components/cell/SettingsSelect.tsx b/gui/src/renderer/components/cell/SettingsSelect.tsx index dd5f9b3b7e4b..4286cda1e22f 100644 --- a/gui/src/renderer/components/cell/SettingsSelect.tsx +++ b/gui/src/renderer/components/cell/SettingsSelect.tsx @@ -87,6 +87,8 @@ interface SettingsSelectProps { items: Array>; onUpdate: (value: T) => void; direction?: 'down' | 'up'; + // eslint-disable-next-line @typescript-eslint/naming-convention + 'data-testid'?: string; } export function SettingsSelect(props: SettingsSelectProps) { @@ -142,7 +144,7 @@ export function SettingsSelect(props: SettingsSelectProps) return ( - + {props.items.find((item) => item.value === value)?.label ?? ''} diff --git a/gui/test/e2e/installed/state-dependent/api-access-methods.spec.ts b/gui/test/e2e/installed/state-dependent/api-access-methods.spec.ts new file mode 100644 index 000000000000..45f8d98ceed7 --- /dev/null +++ b/gui/test/e2e/installed/state-dependent/api-access-methods.spec.ts @@ -0,0 +1,169 @@ +import { expect, test } from '@playwright/test'; +import { Page } from 'playwright'; + +import { startInstalledApp } from '../installed-utils'; +import { TestUtils } from '../../utils'; +import { RoutePath } from '../../../../src/renderer/lib/routes'; + +// This test expects the daemon to be logged in and only have "Direct" and "Mullvad Bridges" +// access methods. +// Env parameters: +// `SHADOWSOCKS_SERVER_IP` +// `SHADOWSOCKS_SERVER_PORT` +// `SHADOWSOCKS_SERVER_CIPHER` +// `SHADOWSOCKS_SERVER_PASSWORD` + +const DIRECT_NAME = 'Direct'; +const BRIDGES_NAME = 'Mullvad Bridges'; +const IN_USE_LABEL = 'In use'; +const FUNCTIONING_METHOD_NAME = 'Test method'; +const NON_FUNCTIONING_METHOD_NAME = 'Non functioning test method'; + +let page: Page; +let util: TestUtils; + +test.beforeAll(async () => { + ({ page, util } = await startInstalledApp()); +}); + +test.afterAll(async () => { + await page.close(); +}); + +async function navigateToAccessMethods() { + await util.waitForNavigation(async () => await page.click('button[aria-label="Settings"]')); + await util.waitForNavigation(async () => await page.getByText('API access').click()); + + const title = page.locator('h1') + await expect(title).toHaveText('API access'); +} + +test('App should display access methods', async () => { + await navigateToAccessMethods(); + + const accessMethods = page.getByTestId('access-method'); + await expect(accessMethods).toHaveCount(2); + + const direct = accessMethods.first(); + const bridges = accessMethods.last(); + await expect(direct).toContainText(DIRECT_NAME); + await expect(bridges).toContainText(BRIDGES_NAME); + await expect(page.getByText(IN_USE_LABEL)).toHaveCount(1); +}); + +test('App should add invalid access method', async () => { + await util.waitForNavigation(async () => await page.locator('button:has-text("Add")').click()); + + const title = page.locator('h1') + await expect(title).toHaveText('Add method'); + + const inputs = page.locator('input'); + const addButton = page.locator('button:has-text("Add")'); + await expect(addButton).toBeVisible(); + await expect(addButton).toBeDisabled(); + + await inputs.first().fill(NON_FUNCTIONING_METHOD_NAME); + await expect(addButton).toBeDisabled(); + + await inputs.nth(1).fill(process.env.SHADOWSOCKS_SERVER_IP!); + await expect(addButton).toBeDisabled(); + + await inputs.nth(2).fill(process.env.SHADOWSOCKS_SERVER_PORT!); + await expect(addButton).toBeEnabled(); + + await addButton.click() + + await expect(page.getByText('Testing method...')).toBeVisible(); + await expect(page.getByText('API unreachable, add anyway?')).toBeVisible(); + + expect( + await util.waitForNavigation(async () => await page.locator('button:has-text("Save")').click()) + ).toEqual(RoutePath.apiAccessMethods); + + const accessMethods = page.getByTestId('access-method'); + await expect(accessMethods).toHaveCount(3); + + await expect(accessMethods.last()).toHaveText(NON_FUNCTIONING_METHOD_NAME); +}); + +test('App should use invalid method', async () => { + const accessMethods = page.getByTestId('access-method'); + const nonFunctioningTestMethod = accessMethods.last(); + + await expect(page.getByText(IN_USE_LABEL)).toHaveCount(1); + await expect(nonFunctioningTestMethod).not.toContainText(IN_USE_LABEL); + + await nonFunctioningTestMethod.locator('button').last().click(); + await nonFunctioningTestMethod.getByText('Use').click(); + await expect(nonFunctioningTestMethod).toContainText('Testing...'); + await expect(nonFunctioningTestMethod).toContainText('API unreachable'); + + await expect(page.getByText(IN_USE_LABEL)).toHaveCount(1); + await expect(nonFunctioningTestMethod).not.toContainText(IN_USE_LABEL); +}); + +test('App should edit access method', async () => { + const customMethod = page.getByTestId('access-method').last(); + await customMethod.locator('button').last().click(); + await util.waitForNavigation(() => customMethod.getByText('Edit').click()); + + const title = page.locator('h1') + await expect(title).toHaveText('Edit method'); + + const inputs = page.locator('input'); + const saveButton = page.locator('button:has-text("Save")'); + await expect(saveButton).toBeVisible(); + await expect(saveButton).toBeEnabled(); + + await expect(inputs.first()).toHaveValue(NON_FUNCTIONING_METHOD_NAME); + await expect(inputs.nth(1)).toHaveValue(process.env.SHADOWSOCKS_SERVER_IP!); + await expect(inputs.nth(2)).toHaveValue(process.env.SHADOWSOCKS_SERVER_PORT!); + + await inputs.first().fill(FUNCTIONING_METHOD_NAME); + await expect(saveButton).toBeEnabled(); + + await inputs.nth(3).fill(process.env.SHADOWSOCKS_SERVER_PASSWORD!); + + await page.getByTestId('ciphers').click(); + await page.getByRole('option', { name: process.env.SHADOWSOCKS_SERVER_CIPHER! }).click(); + + expect( + await util.waitForNavigation(async () => await saveButton.click()) + ).toEqual(RoutePath.apiAccessMethods); + + const accessMethods = page.getByTestId('access-method'); + await expect(accessMethods).toHaveCount(3); + + await expect(accessMethods.last()).toHaveText(FUNCTIONING_METHOD_NAME); +}); + +test('App should use valid method', async () => { + const accessMethods = page.getByTestId('access-method'); + + const direct = accessMethods.first(); + const bridges = accessMethods.nth(1); + const functioningTestMethod = accessMethods.last(); + + await expect(page.getByText(IN_USE_LABEL)).toHaveCount(1); + await expect(functioningTestMethod).not.toContainText(IN_USE_LABEL); + await expect(functioningTestMethod).toHaveText(FUNCTIONING_METHOD_NAME); + + await functioningTestMethod.locator('button').last().click(); + await functioningTestMethod.getByText('Use').click(); + await expect(direct).not.toContainText(IN_USE_LABEL); + await expect(bridges).not.toContainText(IN_USE_LABEL); + await expect(functioningTestMethod).toContainText('API reachable'); + await expect(functioningTestMethod).toContainText(IN_USE_LABEL); +}); + +test('App should delete method', async () => { + const accessMethods = page.getByTestId('access-method'); + const customMethod = accessMethods.last(); + + await customMethod.locator('button').last().click(); + await customMethod.getByText('Delete').click(); + + await expect(page.getByText(`Delete ${FUNCTIONING_METHOD_NAME}?`)).toBeVisible(); + await page.locator('button:has-text("Delete")').click(); + await expect(accessMethods).toHaveCount(2); +});