From 1d1f056c8080d72ef96759605d30d25d1923b7d9 Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Wed, 5 Jun 2024 09:44:45 -0400 Subject: [PATCH] fix: renterd hostd config tests --- .env.example | 6 + .github/workflows/pr.yml | 46 +++- .github/workflows/release-main.yml | 32 +++ README.md | 2 + apps/hostd-e2e/.eslintrc.json | 25 +++ apps/hostd-e2e/.gitignore | 1 + apps/hostd-e2e/playwright.config.ts | 79 +++++++ apps/hostd-e2e/project.json | 19 ++ apps/hostd-e2e/src/fixtures/clearToasts.ts | 11 + apps/hostd-e2e/src/fixtures/click.ts | 23 ++ .../src/fixtures/configResetAllSettings.ts | 71 ++++++ apps/hostd-e2e/src/fixtures/configViewMode.ts | 24 +++ apps/hostd-e2e/src/fixtures/login.ts | 14 ++ .../src/fixtures/navigateToConfig.ts | 6 + .../src/fixtures/navigateToDashboard.ts | 6 + apps/hostd-e2e/src/fixtures/selectInput.ts | 26 +++ apps/hostd-e2e/src/fixtures/switchValue.ts | 34 +++ apps/hostd-e2e/src/fixtures/textInput.ts | 30 +++ apps/hostd-e2e/src/specs/config.spec.ts | 106 +++++++++ apps/hostd-e2e/src/specs/login.spec.ts | 7 + apps/hostd-e2e/tsconfig.json | 17 ++ apps/renterd-e2e/.eslintrc.json | 25 +++ apps/renterd-e2e/.gitignore | 1 + apps/renterd-e2e/playwright.config.ts | 79 +++++++ apps/renterd-e2e/project.json | 19 ++ apps/renterd-e2e/src/fixtures/clearToasts.ts | 11 + apps/renterd-e2e/src/fixtures/click.ts | 23 ++ .../src/fixtures/configResetAllSettings.ts | 55 +++++ .../src/fixtures/configViewMode.ts | 24 +++ apps/renterd-e2e/src/fixtures/login.ts | 16 ++ .../src/fixtures/navigateToBuckets.ts | 6 + .../src/fixtures/navigateToConfig.ts | 6 + apps/renterd-e2e/src/fixtures/selectInput.ts | 26 +++ apps/renterd-e2e/src/fixtures/switchValue.ts | 34 +++ apps/renterd-e2e/src/fixtures/textInput.ts | 30 +++ apps/renterd-e2e/src/specs/config.spec.ts | 202 ++++++++++++++++++ apps/renterd-e2e/src/specs/login.spec.ts | 7 + apps/renterd-e2e/tsconfig.json | 17 ++ .../components/Config/Recommendations.tsx | 2 +- apps/walletd-e2e/src/fixtures/login.ts | 8 +- .../src/form/ConfigurationSwitch.tsx | 1 + libs/design-system/src/lib/toast.tsx | 2 +- server/Caddyfile-dev | 8 +- 43 files changed, 1174 insertions(+), 13 deletions(-) create mode 100644 apps/hostd-e2e/.eslintrc.json create mode 100644 apps/hostd-e2e/.gitignore create mode 100644 apps/hostd-e2e/playwright.config.ts create mode 100644 apps/hostd-e2e/project.json create mode 100644 apps/hostd-e2e/src/fixtures/clearToasts.ts create mode 100644 apps/hostd-e2e/src/fixtures/click.ts create mode 100644 apps/hostd-e2e/src/fixtures/configResetAllSettings.ts create mode 100644 apps/hostd-e2e/src/fixtures/configViewMode.ts create mode 100644 apps/hostd-e2e/src/fixtures/login.ts create mode 100644 apps/hostd-e2e/src/fixtures/navigateToConfig.ts create mode 100644 apps/hostd-e2e/src/fixtures/navigateToDashboard.ts create mode 100644 apps/hostd-e2e/src/fixtures/selectInput.ts create mode 100644 apps/hostd-e2e/src/fixtures/switchValue.ts create mode 100644 apps/hostd-e2e/src/fixtures/textInput.ts create mode 100644 apps/hostd-e2e/src/specs/config.spec.ts create mode 100644 apps/hostd-e2e/src/specs/login.spec.ts create mode 100644 apps/hostd-e2e/tsconfig.json create mode 100644 apps/renterd-e2e/.eslintrc.json create mode 100644 apps/renterd-e2e/.gitignore create mode 100644 apps/renterd-e2e/playwright.config.ts create mode 100644 apps/renterd-e2e/project.json create mode 100644 apps/renterd-e2e/src/fixtures/clearToasts.ts create mode 100644 apps/renterd-e2e/src/fixtures/click.ts create mode 100644 apps/renterd-e2e/src/fixtures/configResetAllSettings.ts create mode 100644 apps/renterd-e2e/src/fixtures/configViewMode.ts create mode 100644 apps/renterd-e2e/src/fixtures/login.ts create mode 100644 apps/renterd-e2e/src/fixtures/navigateToBuckets.ts create mode 100644 apps/renterd-e2e/src/fixtures/navigateToConfig.ts create mode 100644 apps/renterd-e2e/src/fixtures/selectInput.ts create mode 100644 apps/renterd-e2e/src/fixtures/switchValue.ts create mode 100644 apps/renterd-e2e/src/fixtures/textInput.ts create mode 100644 apps/renterd-e2e/src/specs/config.spec.ts create mode 100644 apps/renterd-e2e/src/specs/login.spec.ts create mode 100644 apps/renterd-e2e/tsconfig.json diff --git a/.env.example b/.env.example index 57f9624cf..b208d791a 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,12 @@ GITHUB_TOKEN=secret_token NOTION_TOKEN=secret_token ASSETS=/User/bob/web/assets +HOSTD_E2E_TEST_API_URL=https://hostd.zen.local +HOSTD_E2E_TEST_API_PASSWORD=password +RENTERD_E2E_TEST_API_URL=https://renterd.zen.local +RENTERD_E2E_TEST_API_PASSWORD=password +WALLETD_E2E_TEST_API_URL=https://walletd.zen.local +WALLETD_E2E_TEST_API_PASSWORD=password # Make Go use UTC for time formatting TZ=UTC diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 12362e071..d937803f0 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -41,14 +41,6 @@ jobs: - name: Test TypeScript shell: bash run: npx nx affected --target=test --parallel=5 - - name: Install playwright deps for e2e - shell: bash - run: npx playwright install-deps - - name: Test e2e - shell: bash - run: npx nx affected --target=e2e --parallel=5 - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Build shell: bash run: npx nx affected --target=build --configuration=production --parallel=5 @@ -59,6 +51,44 @@ jobs: shell: bash # issue with parallelism run: npx nx affected --target=build --configuration=export --parallel=5 + test-e2e: + runs-on: ubuntu-latest + # Only run one test-e2e job at a time because they mutate the same data + concurrency: test-e2e + if: ${{ github.event_name == 'pull_request' && github.event.action != 'closed' }} + steps: + - name: Checkout all commits + uses: actions/checkout@v3 + with: + fetch-depth: 0 + # Full setup required since building the JS SDK requires Go + - name: Setup + uses: ./.github/actions/setup-all + with: + node_version: 20.10.0 + go_version: 1.21.7 + # The SDK is referenced via dist in the tsconfig.base.json + # because the next executor does not actually support + # buildLibsFromSource=false + # With this configuration NX does not build the SDK as expected + # when it is an app dependency + - name: Force build SDK + shell: bash + run: npx nx run sdk:build + - name: Install playwright deps for e2e + shell: bash + run: npx playwright install-deps + - name: Test e2e + shell: bash + run: npx nx affected --target=e2e --parallel=5 + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + RENTERD_E2E_TEST_API_URL: ${{ secrets.RENTERD_E2E_TEST_API_URL }} + RENTERD_E2E_TEST_API_PASSWORD: ${{ secrets.RENTERD_E2E_TEST_API_PASSWORD }} + HOSTD_E2E_TEST_API_URL: ${{ secrets.HOSTD_E2E_TEST_API_URL }} + HOSTD_E2E_TEST_API_PASSWORD: ${{ secrets.HOSTD_E2E_TEST_API_PASSWORD }} + WALLETD_E2E_TEST_API_URL: ${{ secrets.WALLETD_E2E_TEST_API_URL }} + WALLETD_E2E_TEST_API_PASSWORD: ${{ secrets.WALLETD_E2E_TEST_API_PASSWORD }} test-go: # Run matrix since Go modules are used across all platforms runs-on: ${{ matrix.os }} diff --git a/.github/workflows/release-main.yml b/.github/workflows/release-main.yml index 76115a324..e0c424ca0 100644 --- a/.github/workflows/release-main.yml +++ b/.github/workflows/release-main.yml @@ -140,6 +140,32 @@ jobs: - name: Test TypeScript shell: bash run: npx nx run-many --target=test --all --parallel=5 + # This job should always pass because the workflow is running run against code that + # was already linted and tested in PR. + # This runs in parallel to the build and release process as an extra check but does + # not actually block the release if job fails. + test-e2e: + runs-on: ubuntu-latest + # Only run one test-e2e job at a time because they mutate the same data + concurrency: test-e2e + steps: + - name: Checkout all commits + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Setup + uses: ./.github/actions/setup-all + with: + node_version: 20.10.0 + go_version: 1.21.7 + # The SDK is referenced via dist in the tsconfig.base.json + # because the next executor does not actually support + # buildLibsFromSource=false + # With this configuration NX does not build the SDK as expected + # when it is an app dependency + - name: Force build SDK + shell: bash + run: npx nx run sdk:build - name: Install playwright deps for e2e shell: bash run: npx playwright install-deps @@ -148,6 +174,12 @@ jobs: run: npx nx run-many --target=e2e --all --parallel=5 env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + RENTERD_E2E_TEST_API_URL: ${{ secrets.RENTERD_E2E_TEST_API_URL }} + RENTERD_E2E_TEST_API_PASSWORD: ${{ secrets.RENTERD_E2E_TEST_API_PASSWORD }} + HOSTD_E2E_TEST_API_URL: ${{ secrets.HOSTD_E2E_TEST_API_URL }} + HOSTD_E2E_TEST_API_PASSWORD: ${{ secrets.HOSTD_E2E_TEST_API_PASSWORD }} + WALLETD_E2E_TEST_API_URL: ${{ secrets.WALLETD_E2E_TEST_API_URL }} + WALLETD_E2E_TEST_API_PASSWORD: ${{ secrets.WALLETD_E2E_TEST_API_PASSWORD }} # This job should always pass because the workflow is running run against code that # was already linted and tested in PR. # This runs in parallel to the build and release process as an extra check but does diff --git a/README.md b/README.md index 3b708da59..5f090a868 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ The Sia web libraries provide developers with convenient TypeScript SDKs for usi ### Testing - [walletd-e2e](walletd-e2e) - App for testing walletd. +- [renterd-e2e](renterd-e2e) - App for testing renterd. +- [hostd-e2e](hostd-e2e) - App for testing hostd. - [@siafoundation/walletd-mock](walletd-mock) - `walletd` data and API mock library for testing. - [@siafoundation/sia-central-mock](sia-central-mock) - Sia Central data and API mock library for testing. diff --git a/apps/hostd-e2e/.eslintrc.json b/apps/hostd-e2e/.eslintrc.json new file mode 100644 index 000000000..d677dc1d6 --- /dev/null +++ b/apps/hostd-e2e/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "extends": ["plugin:playwright/recommended", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["src/**/*.{ts,js,tsx,jsx}"], + "rules": {} + } + ], + "rules": { + "playwright/expect-expect": "off" + } +} diff --git a/apps/hostd-e2e/.gitignore b/apps/hostd-e2e/.gitignore new file mode 100644 index 000000000..53752db25 --- /dev/null +++ b/apps/hostd-e2e/.gitignore @@ -0,0 +1 @@ +output diff --git a/apps/hostd-e2e/playwright.config.ts b/apps/hostd-e2e/playwright.config.ts new file mode 100644 index 000000000..0658c030b --- /dev/null +++ b/apps/hostd-e2e/playwright.config.ts @@ -0,0 +1,79 @@ +import { defineConfig, devices } from '@playwright/test' +import { nxE2EPreset } from '@nx/playwright/preset' + +import { workspaceRoot } from '@nx/devkit' + +// For CI, you may want to set BASE_URL to the deployed application. +const baseURL = process.env['BASE_URL'] || 'https://host.local' + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + ...nxE2EPreset(__filename, { testDir: './src' }), + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + baseURL, + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + video: 'on-first-retry', + }, + expect: { + // Raise the timeout because it is running against next dev mode + // which requires compilation the first to a page is visited + timeout: 10_000, + }, + outputDir: 'output', + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npx nx serve hostd', + url: baseURL, + reuseExistingServer: !process.env.CI, + cwd: workspaceRoot, + }, + // Run the tests serially as they may mutate the state of the same application. + workers: 1, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + // Disable firefox and webkit to save time since tests are running serially. + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + // Uncomment for mobile browsers support + /* { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + { + name: 'Mobile Safari', + use: { ...devices['iPhone 12'] }, + }, */ + + // Uncomment for branded browsers + /* { + name: 'Microsoft Edge', + use: { ...devices['Desktop Edge'], channel: 'msedge' }, + }, + { + name: 'Google Chrome', + use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + } */ + ], +}) diff --git a/apps/hostd-e2e/project.json b/apps/hostd-e2e/project.json new file mode 100644 index 000000000..bcef3187c --- /dev/null +++ b/apps/hostd-e2e/project.json @@ -0,0 +1,19 @@ +{ + "name": "hostd-e2e", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/hostd-e2e/src", + "projectType": "application", + "implicitDependencies": ["hostd"], + "targets": { + "e2e": { + "executor": "@nx/playwright:playwright", + "outputs": ["{workspaceRoot}/dist/.playwright/apps/hostd-e2e"], + "options": { + "config": "apps/hostd-e2e/playwright.config.ts" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/apps/hostd-e2e/src/fixtures/clearToasts.ts b/apps/hostd-e2e/src/fixtures/clearToasts.ts new file mode 100644 index 000000000..3abbf75d8 --- /dev/null +++ b/apps/hostd-e2e/src/fixtures/clearToasts.ts @@ -0,0 +1,11 @@ +import { Page } from '@playwright/test' + +export async function clearToasts({ page }: { page: Page }) { + const clearButtons = page.getByTestId('toasts').locator('button') + while ((await clearButtons.count()) > 0) { + await clearButtons.first().click() + // Timeout required because the toast animation is not instantaneous. + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1000) + } +} diff --git a/apps/hostd-e2e/src/fixtures/click.ts b/apps/hostd-e2e/src/fixtures/click.ts new file mode 100644 index 000000000..d6b1e1d6e --- /dev/null +++ b/apps/hostd-e2e/src/fixtures/click.ts @@ -0,0 +1,23 @@ +import { Locator, expect } from '@playwright/test' + +export async function clickAndWaitIfEnabled( + locator: Locator, + waitForLocator?: Locator +) { + const isDisabled = await locator.isDisabled() + if (!isDisabled) { + await locator.click() + if (waitForLocator) { + await expect(waitForLocator).toBeVisible() + } + return true + } + return false +} + +export async function clickAndWait(locator: Locator, waitForLocator?: Locator) { + await locator.click() + if (waitForLocator) { + await expect(waitForLocator).toBeVisible() + } +} diff --git a/apps/hostd-e2e/src/fixtures/configResetAllSettings.ts b/apps/hostd-e2e/src/fixtures/configResetAllSettings.ts new file mode 100644 index 000000000..546d956ee --- /dev/null +++ b/apps/hostd-e2e/src/fixtures/configResetAllSettings.ts @@ -0,0 +1,71 @@ +import { Page } from '@playwright/test' +import { setSwitchByLabel } from './switchValue' +import { setViewMode } from './configViewMode' +import { fillTextInputByName } from './textInput' +import { fillSelectInputByName } from './selectInput' +import { clearToasts } from './clearToasts' +import { clickAndWaitIfEnabled } from './click' + +export async function configResetAllSettings({ page }: { page: Page }) { + await setViewMode({ page, state: 'advanced' }) + + // host + await setSwitchByLabel(page, 'acceptingContracts', false) + await fillTextInputByName(page, 'netAddress', 'foobar.com:9880') + await fillTextInputByName(page, 'maxContractDuration', '6') + + // pricing + await fillSelectInputByName(page, 'pinnedCurrency', 'USD') + await fillTextInputByName(page, 'pinnedThreshold', '3') + + await setSwitchByLabel(page, 'shouldPinStoragePrice', false) + await fillTextInputByName(page, 'storagePrice', '10') + await setSwitchByLabel(page, 'shouldPinStoragePrice', true) + await fillTextInputByName(page, 'storagePricePinned', '5') + await setSwitchByLabel(page, 'shouldPinStoragePrice', false) + + await setSwitchByLabel(page, 'shouldPinEgressPrice', false) + await fillTextInputByName(page, 'egressPrice', '10') + await setSwitchByLabel(page, 'shouldPinEgressPrice', true) + await fillTextInputByName(page, 'egressPricePinned', '5') + await setSwitchByLabel(page, 'shouldPinEgressPrice', false) + + await setSwitchByLabel(page, 'shouldPinIngressPrice', false) + await fillTextInputByName(page, 'ingressPrice', '10') + await setSwitchByLabel(page, 'shouldPinIngressPrice', true) + await fillTextInputByName(page, 'ingressPricePinned', '5') + await setSwitchByLabel(page, 'shouldPinIngressPrice', false) + + await fillTextInputByName(page, 'collateralMultiplier', '2') + + await setSwitchByLabel(page, 'shouldPinMaxCollateral', false) + await setSwitchByLabel(page, 'shouldPinMaxCollateral', false) + await setSwitchByLabel(page, 'autoMaxCollateral', false) + await fillTextInputByName(page, 'maxCollateral', '10') + await setSwitchByLabel(page, 'shouldPinMaxCollateral', true) + await fillTextInputByName(page, 'maxCollateralPinned', '5') + await setSwitchByLabel(page, 'shouldPinMaxCollateral', false) + + await fillTextInputByName(page, 'contractPrice', '0.2') + await fillTextInputByName(page, 'baseRPCPrice', '1') + await fillTextInputByName(page, 'sectorAccessPrice', '1') + await fillTextInputByName(page, 'priceTableValidity', '30') + + // accounts + await fillTextInputByName(page, 'accountExpiry', '30') + await fillTextInputByName(page, 'maxAccountBalance', '10') + + // bandwidth + await fillTextInputByName(page, 'ingressLimit', '0') + await fillTextInputByName(page, 'egressLimit', '0') + + // DNS + await fillSelectInputByName(page, 'dnsProvider', '') + + // save + await clickAndWaitIfEnabled( + page.getByText('Save changes'), + page.getByText('Settings have been saved') + ) + await clearToasts({ page }) +} diff --git a/apps/hostd-e2e/src/fixtures/configViewMode.ts b/apps/hostd-e2e/src/fixtures/configViewMode.ts new file mode 100644 index 000000000..48bdcc112 --- /dev/null +++ b/apps/hostd-e2e/src/fixtures/configViewMode.ts @@ -0,0 +1,24 @@ +import { Page } from '@playwright/test' +import { getSwitchByLabel } from './switchValue' + +export async function setViewMode({ + page, + state, +}: { + page: Page + state: 'advanced' | 'basic' +}) { + const { el, value } = await getViewMode({ page }) + if (state !== value) { + await el.click() + } +} + +export async function getViewMode({ page }: { page: Page }) { + await page.getByRole('button', { name: 'View' }).click() + const { el, value } = await getSwitchByLabel(page, 'configViewMode') + return { + el, + value: value ? 'advanced' : 'basic', + } +} diff --git a/apps/hostd-e2e/src/fixtures/login.ts b/apps/hostd-e2e/src/fixtures/login.ts new file mode 100644 index 000000000..59b153dcf --- /dev/null +++ b/apps/hostd-e2e/src/fixtures/login.ts @@ -0,0 +1,14 @@ +import { Page, expect } from '@playwright/test' + +export async function login({ page }: { page: Page }) { + await page.goto('/') + await expect(page).toHaveTitle('hostd') + await page.getByLabel('login settings').click() + await page.getByRole('menuitem', { name: 'Show custom API' }).click() + await page.locator('input[name=api]').fill(process.env.HOSTD_E2E_TEST_API_URL) + await page.locator('input[name=api]').press('Tab') + await page + .locator('input[name=password]') + .fill(process.env.HOSTD_E2E_TEST_API_PASSWORD) + await page.locator('input[name=password]').press('Enter') +} diff --git a/apps/hostd-e2e/src/fixtures/navigateToConfig.ts b/apps/hostd-e2e/src/fixtures/navigateToConfig.ts new file mode 100644 index 000000000..bb68cb2ff --- /dev/null +++ b/apps/hostd-e2e/src/fixtures/navigateToConfig.ts @@ -0,0 +1,6 @@ +import { Page, expect } from '@playwright/test' + +export async function navigateToConfig({ page }: { page: Page }) { + await page.getByLabel('Configuration').click() + await expect(page.locator('#navbar').getByText('Configuration')).toBeVisible() +} diff --git a/apps/hostd-e2e/src/fixtures/navigateToDashboard.ts b/apps/hostd-e2e/src/fixtures/navigateToDashboard.ts new file mode 100644 index 000000000..06e4cf108 --- /dev/null +++ b/apps/hostd-e2e/src/fixtures/navigateToDashboard.ts @@ -0,0 +1,6 @@ +import { Page, expect } from '@playwright/test' + +export async function navigateToDashboard({ page }: { page: Page }) { + await page.getByLabel('Overview').click() + await expect(page.locator('#navbar').getByText('Overview')).toBeVisible() +} diff --git a/apps/hostd-e2e/src/fixtures/selectInput.ts b/apps/hostd-e2e/src/fixtures/selectInput.ts new file mode 100644 index 000000000..2b7a628f0 --- /dev/null +++ b/apps/hostd-e2e/src/fixtures/selectInput.ts @@ -0,0 +1,26 @@ +import { Page, expect } from '@playwright/test' + +export async function fillSelectInputByName( + page: Page, + name: string, + value: string +) { + await page.locator(`select[name="${name}"]`).click() + await page.locator(`select[name="${name}"]`).selectOption(value) +} + +export async function expectSelectInputByName( + page: Page, + name: string, + value: string +) { + await expect(page.locator(`select[name="${name}"]`)).toHaveValue(value) +} + +export async function expectSelectInputByNameAttribute( + page: Page, + name: string, + value: string +) { + await expect(page.locator(`select[name="${name}"]`)).toHaveAttribute(value) +} diff --git a/apps/hostd-e2e/src/fixtures/switchValue.ts b/apps/hostd-e2e/src/fixtures/switchValue.ts new file mode 100644 index 000000000..b23cc5edb --- /dev/null +++ b/apps/hostd-e2e/src/fixtures/switchValue.ts @@ -0,0 +1,34 @@ +import { Page, expect } from '@playwright/test' + +export async function setSwitchByLabel( + page: Page, + label: string, + state: boolean +) { + const { el, value } = await getSwitchByLabel(page, label) + if (state !== value) { + await el.click() + } + await expect(el).toHaveAttribute( + 'data-state', + state ? 'checked' : 'unchecked' + ) +} + +export async function getSwitchByLabel(page: Page, label: string) { + const el = page.getByLabel(label) + const value = (await el.getAttribute('data-state')) as 'checked' | 'unchecked' + return { + el, + value: value === 'checked', + } +} + +export async function expectSwitchByLabel( + page: Page, + label: string, + value: boolean +) { + const { value: actualValue } = await getSwitchByLabel(page, label) + expect(actualValue).toBe(value) +} diff --git a/apps/hostd-e2e/src/fixtures/textInput.ts b/apps/hostd-e2e/src/fixtures/textInput.ts new file mode 100644 index 000000000..2e0fc736e --- /dev/null +++ b/apps/hostd-e2e/src/fixtures/textInput.ts @@ -0,0 +1,30 @@ +import { Page, expect } from '@playwright/test' + +export async function fillTextInputByName( + page: Page, + name: string, + value: string +) { + await page.locator(`input[name="${name}"]`).click() + await page.locator(`input[name="${name}"]`).fill(value) +} + +export async function expectTextInputByName( + page: Page, + name: string, + value: string +) { + await expect(page.locator(`input[name="${name}"]`)).toHaveValue(value) +} + +export async function expectTextInputNotVisible(page: Page, name: string) { + await expect(page.locator(`input[name="${name}"]`)).toBeHidden() +} + +export async function expectTextInputByNameAttribute( + page: Page, + name: string, + value: string +) { + await expect(page.locator(`input[name="${name}"]`)).toHaveAttribute(value) +} diff --git a/apps/hostd-e2e/src/specs/config.spec.ts b/apps/hostd-e2e/src/specs/config.spec.ts new file mode 100644 index 000000000..e2de04c1e --- /dev/null +++ b/apps/hostd-e2e/src/specs/config.spec.ts @@ -0,0 +1,106 @@ +import { test, expect } from '@playwright/test' +import { login } from '../fixtures/login' +import { expectSwitchByLabel, setSwitchByLabel } from '../fixtures/switchValue' +import { setViewMode } from '../fixtures/configViewMode' +import { navigateToConfig } from '../fixtures/navigateToConfig' +import { mockApiSiaCentralExchangeRates } from '@siafoundation/sia-central-mock' +import { configResetAllSettings } from '../fixtures/configResetAllSettings' +import { + expectTextInputByName, + expectTextInputByNameAttribute, + expectTextInputNotVisible, + fillTextInputByName, +} from '../fixtures/textInput' +import { fillSelectInputByName } from '../fixtures/selectInput' + +test('basic field change and save behaviour', async ({ page }) => { + // Set up. + await mockApiSiaCentralExchangeRates({ page }) + await login({ page }) + + // Reset state. + await navigateToConfig({ page }) + await configResetAllSettings({ page }) + await setSwitchByLabel(page, 'autoMaxCollateral', true) + await setViewMode({ page, state: 'advanced' }) + + // Test that values can be updated + await setSwitchByLabel(page, 'acceptingContracts', true) + await fillTextInputByName(page, 'netAddress', 'foobar.com:777') + await fillTextInputByName(page, 'maxContractDuration', '7') + await fillSelectInputByName(page, 'pinnedCurrency', 'AUD') + await fillTextInputByName(page, 'pinnedThreshold', '7') + await setSwitchByLabel(page, 'shouldPinStoragePrice', true) + await fillTextInputByName(page, 'storagePricePinned', '77') + await fillTextInputByName(page, 'egressPrice', '77') + await fillTextInputByName(page, 'baseRPCPrice', '77') + + // Correct number of changes is shown. + await expect(page.getByText('10 changes')).toBeVisible() + await page.getByText('Save changes').click() + await expect(page.getByText('10 changes')).toBeHidden() + + // Values are the same after save + await expectSwitchByLabel(page, 'acceptingContracts', true) + // Address change detected. + await expect( + page.getByText('Address has changed, make sure to re-announce the host.') + ).toBeVisible() + await expectTextInputByName(page, 'netAddress', 'foobar.com:777') + await expectTextInputByName(page, 'maxContractDuration', '7') + await fillSelectInputByName(page, 'pinnedCurrency', 'USD') + await expectTextInputByName(page, 'pinnedThreshold', '7') + // Pinned vs not pinned fields correctly shown or hidden. + await expectSwitchByLabel(page, 'shouldPinStoragePrice', true) + await expectTextInputByName(page, 'storagePricePinned', '$77') + await expectTextInputNotVisible(page, 'storagePrice') + await expectSwitchByLabel(page, 'shouldPinEgressPrice', false) + await expectTextInputByName(page, 'egressPrice', '77') + await expectTextInputNotVisible(page, 'egressPricePinned') + await expectTextInputByName(page, 'baseRPCPrice', '77') +}) + +test('configure with auto max collateral', async ({ page }) => { + // Set up. + await mockApiSiaCentralExchangeRates({ page }) + await login({ page }) + + // Reset state. + await navigateToConfig({ page }) + await configResetAllSettings({ page }) + await setViewMode({ page, state: 'basic' }) + await fillTextInputByName(page, 'maxCollateral', '777') + await setSwitchByLabel(page, 'autoMaxCollateral', true) + + // Set all values that affect the max collateral calculation. + await fillTextInputByName(page, 'storagePrice', '10') + await fillTextInputByName(page, 'collateralMultiplier', '10') + await expectSwitchByLabel(page, 'autoMaxCollateral', true) + // Max collateral auto updated. + await expectTextInputByName(page, 'maxCollateral', '300') + // Max collateral cannot be manually updated. + await expectTextInputByNameAttribute(page, 'maxCollateral', 'readOnly') +}) + +test('configure with manual max collateral', async ({ page }) => { + // Set up. + await mockApiSiaCentralExchangeRates({ page }) + await login({ page }) + + // Reset state. + await navigateToConfig({ page }) + await configResetAllSettings({ page }) + await setViewMode({ page, state: 'basic' }) + await fillTextInputByName(page, 'maxCollateral', '777') + await setSwitchByLabel(page, 'autoMaxCollateral', false) + + // Set all values that affect the max collateral calculation. + await fillTextInputByName(page, 'storagePrice', '10') + await fillTextInputByName(page, 'collateralMultiplier', '10') + await expectSwitchByLabel(page, 'autoMaxCollateral', false) + // Max collateral did not auto update. + await expectTextInputByName(page, 'maxCollateral', '777') + // Max collateral can be manually updated. + await fillTextInputByName(page, 'maxCollateral', '4000') + await expectTextInputByName(page, 'maxCollateral', '4,000') +}) diff --git a/apps/hostd-e2e/src/specs/login.spec.ts b/apps/hostd-e2e/src/specs/login.spec.ts new file mode 100644 index 000000000..8f3297d2e --- /dev/null +++ b/apps/hostd-e2e/src/specs/login.spec.ts @@ -0,0 +1,7 @@ +import { test, expect } from '@playwright/test' +import { login } from '../fixtures/login' + +test('login', async ({ page }) => { + await login({ page }) + await expect(page.locator('#navbar').getByText('Overview')).toBeVisible() +}) diff --git a/apps/hostd-e2e/tsconfig.json b/apps/hostd-e2e/tsconfig.json new file mode 100644 index 000000000..fd1b9df91 --- /dev/null +++ b/apps/hostd-e2e/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "preserve", + "allowJs": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "resolveJsonModule": true, + "isolatedModules": true, + "incremental": true, + "types": ["jest", "node"] + }, + "include": ["**/*.ts", "**/*.js"] +} diff --git a/apps/renterd-e2e/.eslintrc.json b/apps/renterd-e2e/.eslintrc.json new file mode 100644 index 000000000..d677dc1d6 --- /dev/null +++ b/apps/renterd-e2e/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "extends": ["plugin:playwright/recommended", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["src/**/*.{ts,js,tsx,jsx}"], + "rules": {} + } + ], + "rules": { + "playwright/expect-expect": "off" + } +} diff --git a/apps/renterd-e2e/.gitignore b/apps/renterd-e2e/.gitignore new file mode 100644 index 000000000..53752db25 --- /dev/null +++ b/apps/renterd-e2e/.gitignore @@ -0,0 +1 @@ +output diff --git a/apps/renterd-e2e/playwright.config.ts b/apps/renterd-e2e/playwright.config.ts new file mode 100644 index 000000000..957174fac --- /dev/null +++ b/apps/renterd-e2e/playwright.config.ts @@ -0,0 +1,79 @@ +import { defineConfig, devices } from '@playwright/test' +import { nxE2EPreset } from '@nx/playwright/preset' + +import { workspaceRoot } from '@nx/devkit' + +// For CI, you may want to set BASE_URL to the deployed application. +const baseURL = process.env['BASE_URL'] || 'https://renter.local' + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + ...nxE2EPreset(__filename, { testDir: './src' }), + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + baseURL, + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + video: 'on-first-retry', + }, + expect: { + // Raise the timeout because it is running against next dev mode + // which requires compilation the first to a page is visited + timeout: 10_000, + }, + outputDir: 'output', + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npx nx serve renterd', + url: baseURL, + reuseExistingServer: !process.env.CI, + cwd: workspaceRoot, + }, + // Run the tests serially as they may mutate the state of the same application. + workers: 1, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + // Disable firefox and webkit to save time since tests are running serially. + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + // Uncomment for mobile browsers support + /* { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + { + name: 'Mobile Safari', + use: { ...devices['iPhone 12'] }, + }, */ + + // Uncomment for branded browsers + /* { + name: 'Microsoft Edge', + use: { ...devices['Desktop Edge'], channel: 'msedge' }, + }, + { + name: 'Google Chrome', + use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + } */ + ], +}) diff --git a/apps/renterd-e2e/project.json b/apps/renterd-e2e/project.json new file mode 100644 index 000000000..b0aace4aa --- /dev/null +++ b/apps/renterd-e2e/project.json @@ -0,0 +1,19 @@ +{ + "name": "renterd-e2e", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/renterd-e2e/src", + "projectType": "application", + "implicitDependencies": ["renterd"], + "targets": { + "e2e": { + "executor": "@nx/playwright:playwright", + "outputs": ["{workspaceRoot}/dist/.playwright/apps/renterd-e2e"], + "options": { + "config": "apps/renterd-e2e/playwright.config.ts" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/apps/renterd-e2e/src/fixtures/clearToasts.ts b/apps/renterd-e2e/src/fixtures/clearToasts.ts new file mode 100644 index 000000000..3abbf75d8 --- /dev/null +++ b/apps/renterd-e2e/src/fixtures/clearToasts.ts @@ -0,0 +1,11 @@ +import { Page } from '@playwright/test' + +export async function clearToasts({ page }: { page: Page }) { + const clearButtons = page.getByTestId('toasts').locator('button') + while ((await clearButtons.count()) > 0) { + await clearButtons.first().click() + // Timeout required because the toast animation is not instantaneous. + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1000) + } +} diff --git a/apps/renterd-e2e/src/fixtures/click.ts b/apps/renterd-e2e/src/fixtures/click.ts new file mode 100644 index 000000000..d6b1e1d6e --- /dev/null +++ b/apps/renterd-e2e/src/fixtures/click.ts @@ -0,0 +1,23 @@ +import { Locator, expect } from '@playwright/test' + +export async function clickAndWaitIfEnabled( + locator: Locator, + waitForLocator?: Locator +) { + const isDisabled = await locator.isDisabled() + if (!isDisabled) { + await locator.click() + if (waitForLocator) { + await expect(waitForLocator).toBeVisible() + } + return true + } + return false +} + +export async function clickAndWait(locator: Locator, waitForLocator?: Locator) { + await locator.click() + if (waitForLocator) { + await expect(waitForLocator).toBeVisible() + } +} diff --git a/apps/renterd-e2e/src/fixtures/configResetAllSettings.ts b/apps/renterd-e2e/src/fixtures/configResetAllSettings.ts new file mode 100644 index 000000000..db9330b4a --- /dev/null +++ b/apps/renterd-e2e/src/fixtures/configResetAllSettings.ts @@ -0,0 +1,55 @@ +import { Page } from '@playwright/test' +import { setSwitchByLabel } from './switchValue' +import { setViewMode } from './configViewMode' +import { fillTextInputByName } from './textInput' +import { clearToasts } from './clearToasts' +import { clickAndWaitIfEnabled } from './click' + +export async function configResetAllSettings({ page }: { page: Page }) { + await setViewMode({ page, state: 'advanced' }) + await setSwitchByLabel(page, 'autoAllowance', false) + + // storage + await fillTextInputByName(page, 'storageTB', '1') + await fillTextInputByName(page, 'uploadTBMonth', '1') + await fillTextInputByName(page, 'downloadTBMonth', '1') + await fillTextInputByName(page, 'allowanceMonth', '1') + await fillTextInputByName(page, 'periodWeeks', '6') + await fillTextInputByName(page, 'renewWindowWeeks', '2') + await fillTextInputByName(page, 'amountHosts', '12') + await fillTextInputByName(page, 'autopilotContractSet', 'autopilot') + await setSwitchByLabel(page, 'prune', false) + + // hosts + await setSwitchByLabel(page, 'allowRedundantIPs', false) + await fillTextInputByName(page, 'maxDowntimeHours', '7') + await fillTextInputByName(page, 'minRecentScanFailures', '1') + await fillTextInputByName(page, 'minProtocolVersion', '1.6.0') + + // contracts + await fillTextInputByName(page, 'defaultContractSet', 'autopilot') + await setSwitchByLabel(page, 'uploadPackingEnabled', true) + + // gouging + await fillTextInputByName(page, 'maxStoragePriceTBMonth', '3000') + await fillTextInputByName(page, 'maxUploadPriceTB', '3000') + await fillTextInputByName(page, 'maxDownloadPriceTB', '3000') + await fillTextInputByName(page, 'maxContractPrice', '1') + await fillTextInputByName(page, 'maxRpcPriceMillion', '10') + await fillTextInputByName(page, 'hostBlockHeightLeeway', '12') + await fillTextInputByName(page, 'minPriceTableValidityMinutes', '5') + await fillTextInputByName(page, 'minAccountExpiryDays', '1') + await fillTextInputByName(page, 'minMaxEphemeralAccountBalance', '1') + await fillTextInputByName(page, 'migrationSurchargeMultiplier', '1') + + // redundancy + await fillTextInputByName(page, 'minShards', '2') + await fillTextInputByName(page, 'totalShards', '6') + + // save + await clickAndWaitIfEnabled( + page.getByText('Save changes'), + page.getByText('Configuration has been saved') + ) + await clearToasts({ page }) +} diff --git a/apps/renterd-e2e/src/fixtures/configViewMode.ts b/apps/renterd-e2e/src/fixtures/configViewMode.ts new file mode 100644 index 000000000..48bdcc112 --- /dev/null +++ b/apps/renterd-e2e/src/fixtures/configViewMode.ts @@ -0,0 +1,24 @@ +import { Page } from '@playwright/test' +import { getSwitchByLabel } from './switchValue' + +export async function setViewMode({ + page, + state, +}: { + page: Page + state: 'advanced' | 'basic' +}) { + const { el, value } = await getViewMode({ page }) + if (state !== value) { + await el.click() + } +} + +export async function getViewMode({ page }: { page: Page }) { + await page.getByRole('button', { name: 'View' }).click() + const { el, value } = await getSwitchByLabel(page, 'configViewMode') + return { + el, + value: value ? 'advanced' : 'basic', + } +} diff --git a/apps/renterd-e2e/src/fixtures/login.ts b/apps/renterd-e2e/src/fixtures/login.ts new file mode 100644 index 000000000..1f20d6841 --- /dev/null +++ b/apps/renterd-e2e/src/fixtures/login.ts @@ -0,0 +1,16 @@ +import { Page, expect } from '@playwright/test' + +export async function login({ page }: { page: Page }) { + await page.goto('/') + await expect(page).toHaveTitle('renterd') + await page.getByLabel('login settings').click() + await page.getByRole('menuitem', { name: 'Show custom API' }).click() + await page + .locator('input[name=api]') + .fill(process.env.RENTERD_E2E_TEST_API_URL) + await page.locator('input[name=api]').press('Tab') + await page + .locator('input[name=password]') + .fill(process.env.RENTERD_E2E_TEST_API_PASSWORD) + await page.locator('input[name=password]').press('Enter') +} diff --git a/apps/renterd-e2e/src/fixtures/navigateToBuckets.ts b/apps/renterd-e2e/src/fixtures/navigateToBuckets.ts new file mode 100644 index 000000000..b11f4b477 --- /dev/null +++ b/apps/renterd-e2e/src/fixtures/navigateToBuckets.ts @@ -0,0 +1,6 @@ +import { Page, expect } from '@playwright/test' + +export async function navigateToBuckets({ page }: { page: Page }) { + await page.getByLabel('Files').click() + await expect(page.locator('#navbar').getByText('Buckets')).toBeVisible() +} diff --git a/apps/renterd-e2e/src/fixtures/navigateToConfig.ts b/apps/renterd-e2e/src/fixtures/navigateToConfig.ts new file mode 100644 index 000000000..bb68cb2ff --- /dev/null +++ b/apps/renterd-e2e/src/fixtures/navigateToConfig.ts @@ -0,0 +1,6 @@ +import { Page, expect } from '@playwright/test' + +export async function navigateToConfig({ page }: { page: Page }) { + await page.getByLabel('Configuration').click() + await expect(page.locator('#navbar').getByText('Configuration')).toBeVisible() +} diff --git a/apps/renterd-e2e/src/fixtures/selectInput.ts b/apps/renterd-e2e/src/fixtures/selectInput.ts new file mode 100644 index 000000000..2b7a628f0 --- /dev/null +++ b/apps/renterd-e2e/src/fixtures/selectInput.ts @@ -0,0 +1,26 @@ +import { Page, expect } from '@playwright/test' + +export async function fillSelectInputByName( + page: Page, + name: string, + value: string +) { + await page.locator(`select[name="${name}"]`).click() + await page.locator(`select[name="${name}"]`).selectOption(value) +} + +export async function expectSelectInputByName( + page: Page, + name: string, + value: string +) { + await expect(page.locator(`select[name="${name}"]`)).toHaveValue(value) +} + +export async function expectSelectInputByNameAttribute( + page: Page, + name: string, + value: string +) { + await expect(page.locator(`select[name="${name}"]`)).toHaveAttribute(value) +} diff --git a/apps/renterd-e2e/src/fixtures/switchValue.ts b/apps/renterd-e2e/src/fixtures/switchValue.ts new file mode 100644 index 000000000..b23cc5edb --- /dev/null +++ b/apps/renterd-e2e/src/fixtures/switchValue.ts @@ -0,0 +1,34 @@ +import { Page, expect } from '@playwright/test' + +export async function setSwitchByLabel( + page: Page, + label: string, + state: boolean +) { + const { el, value } = await getSwitchByLabel(page, label) + if (state !== value) { + await el.click() + } + await expect(el).toHaveAttribute( + 'data-state', + state ? 'checked' : 'unchecked' + ) +} + +export async function getSwitchByLabel(page: Page, label: string) { + const el = page.getByLabel(label) + const value = (await el.getAttribute('data-state')) as 'checked' | 'unchecked' + return { + el, + value: value === 'checked', + } +} + +export async function expectSwitchByLabel( + page: Page, + label: string, + value: boolean +) { + const { value: actualValue } = await getSwitchByLabel(page, label) + expect(actualValue).toBe(value) +} diff --git a/apps/renterd-e2e/src/fixtures/textInput.ts b/apps/renterd-e2e/src/fixtures/textInput.ts new file mode 100644 index 000000000..2e0fc736e --- /dev/null +++ b/apps/renterd-e2e/src/fixtures/textInput.ts @@ -0,0 +1,30 @@ +import { Page, expect } from '@playwright/test' + +export async function fillTextInputByName( + page: Page, + name: string, + value: string +) { + await page.locator(`input[name="${name}"]`).click() + await page.locator(`input[name="${name}"]`).fill(value) +} + +export async function expectTextInputByName( + page: Page, + name: string, + value: string +) { + await expect(page.locator(`input[name="${name}"]`)).toHaveValue(value) +} + +export async function expectTextInputNotVisible(page: Page, name: string) { + await expect(page.locator(`input[name="${name}"]`)).toBeHidden() +} + +export async function expectTextInputByNameAttribute( + page: Page, + name: string, + value: string +) { + await expect(page.locator(`input[name="${name}"]`)).toHaveAttribute(value) +} diff --git a/apps/renterd-e2e/src/specs/config.spec.ts b/apps/renterd-e2e/src/specs/config.spec.ts new file mode 100644 index 000000000..bd4f72c2d --- /dev/null +++ b/apps/renterd-e2e/src/specs/config.spec.ts @@ -0,0 +1,202 @@ +import { test, expect } from '@playwright/test' +import { login } from '../fixtures/login' +import { expectSwitchByLabel, setSwitchByLabel } from '../fixtures/switchValue' +import { setViewMode } from '../fixtures/configViewMode' +import { navigateToConfig } from '../fixtures/navigateToConfig' +import { mockApiSiaCentralExchangeRates } from '@siafoundation/sia-central-mock' +import { + expectTextInputByName, + expectTextInputByNameAttribute, + fillTextInputByName, +} from '../fixtures/textInput' +import { configResetAllSettings } from '../fixtures/configResetAllSettings' +import { clearToasts } from '../fixtures/clearToasts' +import { clickAndWaitIfEnabled } from '../fixtures/click' + +test('basic field change and save behaviour', async ({ page }) => { + // Set up. + await mockApiSiaCentralExchangeRates({ page }) + await login({ page }) + + // Reset state. + await navigateToConfig({ page }) + await configResetAllSettings({ page }) + await setViewMode({ page, state: 'basic' }) + await setSwitchByLabel(page, 'autoAllowance', true) + + // Test that values can be updated. + await fillTextInputByName(page, 'storageTB', '7') + await fillTextInputByName(page, 'uploadTBMonth', '7') + await fillTextInputByName(page, 'downloadTBMonth', '7') + await fillTextInputByName(page, 'maxStoragePriceTBMonth', '7000') + await fillTextInputByName(page, 'maxUploadPriceTB', '7000') + await fillTextInputByName(page, 'maxDownloadPriceTB', '7000') + + // Correct number of changes is shown. + await expect(page.getByText('7 changes')).toBeVisible() + await page.getByText('Save changes').click() + await expect(page.getByText('7 changes')).toBeHidden() + + // Values are the same after save. + await expectTextInputByName(page, 'storageTB', '7') + await expectTextInputByName(page, 'uploadTBMonth', '7') + await expectTextInputByName(page, 'downloadTBMonth', '7') + await expectTextInputByName(page, 'maxStoragePriceTBMonth', '7,000') + await expectTextInputByName(page, 'maxUploadPriceTB', '7,000') + await expectTextInputByName(page, 'maxDownloadPriceTB', '7,000') + await expectTextInputByName(page, 'allowanceMonth', '343,000') +}) + +test('estimate based off storage, pricing, and redundancy', async ({ + page, +}) => { + // Set up. + await mockApiSiaCentralExchangeRates({ page }) + await login({ page }) + + // Reset state. + await navigateToConfig({ page }) + await setSwitchByLabel(page, 'autoAllowance', true) + await setViewMode({ page, state: 'advanced' }) + + await fillTextInputByName(page, 'storageTB', '10') + await fillTextInputByName(page, 'uploadTBMonth', '10') + await fillTextInputByName(page, 'downloadTBMonth', '4') + await fillTextInputByName(page, 'maxStoragePriceTBMonth', '1500') + await fillTextInputByName(page, 'maxUploadPriceTB', '1000') + await fillTextInputByName(page, 'maxDownloadPriceTB', '5000') + await fillTextInputByName(page, 'minShards', '2') + await fillTextInputByName(page, 'totalShards', '8') + + const estimateParts = [ + 'Estimate', + '12,000 SC', + '($126.86)', + 'per TB/month with 4.0x redundancy', + '120,000 SC', + '($1268.56)', + 'to store 10.00 TB/month with 4.0x redundancy', + ] + + for (const part of estimateParts) { + await expect(page.getByText(part)).toBeVisible() + } +}) + +test('configure with auto allowance', async ({ page }) => { + // Set up. + await mockApiSiaCentralExchangeRates({ page }) + await login({ page }) + + // Reset state. + await navigateToConfig({ page }) + await setSwitchByLabel(page, 'autoAllowance', true) + await setViewMode({ page, state: 'basic' }) + + // Set all values that affect the allowance calculation. + await fillTextInputByName(page, 'storageTB', '10') + await fillTextInputByName(page, 'uploadTBMonth', '10') + await fillTextInputByName(page, 'downloadTBMonth', '4') + await fillTextInputByName(page, 'maxStoragePriceTBMonth', '1500') + await fillTextInputByName(page, 'maxUploadPriceTB', '1000') + await fillTextInputByName(page, 'maxDownloadPriceTB', '5000') + await expectSwitchByLabel(page, 'autoAllowance', true) + // Allowance auto updated. + await expectTextInputByName(page, 'allowanceMonth', '95,000') + // Allowance cannot be manually updated. + await expectTextInputByNameAttribute(page, 'allowanceMonth', 'readOnly') +}) + +test('configure allowance manually', async ({ page }) => { + // Set up. + await mockApiSiaCentralExchangeRates({ page }) + await login({ page }) + + // Reset state. + await navigateToConfig({ page }) + await configResetAllSettings({ page }) + await setSwitchByLabel(page, 'autoAllowance', false) + await setViewMode({ page, state: 'basic' }) + await fillTextInputByName(page, 'allowanceMonth', '777') + + // Set all values that affect the allowance calculation. + await fillTextInputByName(page, 'storageTB', '10') + await fillTextInputByName(page, 'uploadTBMonth', '10') + await fillTextInputByName(page, 'downloadTBMonth', '4') + await fillTextInputByName(page, 'maxStoragePriceTBMonth', '1500') + await fillTextInputByName(page, 'maxUploadPriceTB', '1000') + await fillTextInputByName(page, 'maxDownloadPriceTB', '5000') + await expectSwitchByLabel(page, 'autoAllowance', false) + // Allowance did not auto update. + await expectTextInputByName(page, 'allowanceMonth', '777') + // Allowance can be manually updated. + await fillTextInputByName(page, 'allowanceMonth', '4000') + await expectTextInputByName(page, 'allowanceMonth', '4,000') +}) + +test('system offers recommendations', async ({ page }) => { + // Set up. + await mockApiSiaCentralExchangeRates({ page }) + await login({ page }) + + // Reset state. + await navigateToConfig({ page }) + await configResetAllSettings({ page }) + await setViewMode({ page, state: 'basic' }) + await setSwitchByLabel(page, 'autoAllowance', true) + + // Reset to very high values that will not need any recommendations. + await fillTextInputByName(page, 'storageTB', '1') + await fillTextInputByName(page, 'uploadTBMonth', '1') + await fillTextInputByName(page, 'downloadTBMonth', '1') + await fillTextInputByName(page, 'maxStoragePriceTBMonth', '100000000') + await fillTextInputByName(page, 'maxUploadPriceTB', '100000000') + await fillTextInputByName(page, 'maxDownloadPriceTB', '100000000') + await clickAndWaitIfEnabled( + page.getByText('Save changes'), + page.getByText('Configuration has been saved') + ) + await clearToasts({ page }) + + await expect( + page.getByText( + /(0 recommendations to match with more hosts|Configuration matches with a sufficient number of hosts)/ + ) + ).toBeVisible() + + await fillTextInputByName(page, 'storageTB', '10') + await fillTextInputByName(page, 'uploadTBMonth', '10') + await fillTextInputByName(page, 'downloadTBMonth', '4') + await fillTextInputByName(page, 'maxStoragePriceTBMonth', '100') + await fillTextInputByName(page, 'maxUploadPriceTB', '100') + await fillTextInputByName(page, 'maxDownloadPriceTB', '100') + await clickAndWaitIfEnabled( + page.getByText('Save changes'), + page.getByText('Configuration has been saved') + ) + await clearToasts({ page }) + // There are now recommendations. + await expect( + page.getByText('0 recommendations to match with more hosts') + ).toBeHidden() + + // Apply all recommendations. + let count = await page + .locator('#recommendationsList > *') + .evaluateAll((elements) => elements.length) + expect(count).toBeGreaterThan(0) + while (count > 0) { + const button = page.locator('#recommendationsList button').first() + await button.click() + count = await page + .locator('#recommendationsList > *') + .evaluateAll((elements) => elements.length) + } + + // Check that all recommendations were applied and there are changes to the config. + // TODO: disabled because sometimes the "increase value" recommendation is + // replaced with a "decrease value" recommendation with the same value. + // await expect(page.locator('#recommendationsList')).toBeHidden() + + await expect(page.getByText(`Save changes`)).toBeEnabled() +}) diff --git a/apps/renterd-e2e/src/specs/login.spec.ts b/apps/renterd-e2e/src/specs/login.spec.ts new file mode 100644 index 000000000..e1d4672e2 --- /dev/null +++ b/apps/renterd-e2e/src/specs/login.spec.ts @@ -0,0 +1,7 @@ +import { test, expect } from '@playwright/test' +import { login } from '../fixtures/login' + +test('login', async ({ page }) => { + await login({ page }) + await expect(page.locator('#navbar').getByText('Buckets')).toBeVisible() +}) diff --git a/apps/renterd-e2e/tsconfig.json b/apps/renterd-e2e/tsconfig.json new file mode 100644 index 000000000..fd1b9df91 --- /dev/null +++ b/apps/renterd-e2e/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "preserve", + "allowJs": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "resolveJsonModule": true, + "isolatedModules": true, + "incremental": true, + "types": ["jest", "node"] + }, + "include": ["**/*.ts", "**/*.js"] +} diff --git a/apps/renterd/components/Config/Recommendations.tsx b/apps/renterd/components/Config/Recommendations.tsx index 9208eff3d..4c2ffd772 100644 --- a/apps/renterd/components/Config/Recommendations.tsx +++ b/apps/renterd/components/Config/Recommendations.tsx @@ -294,7 +294,7 @@ function Layout({
{tip ? {tip} : el} - {children} + {children &&
{children}
}
diff --git a/apps/walletd-e2e/src/fixtures/login.ts b/apps/walletd-e2e/src/fixtures/login.ts index 7194cef2a..c7a451792 100644 --- a/apps/walletd-e2e/src/fixtures/login.ts +++ b/apps/walletd-e2e/src/fixtures/login.ts @@ -5,8 +5,12 @@ export async function login({ page }: { page: Page }) { await expect(page).toHaveTitle('walletd') await page.getByLabel('login settings').click() await page.getByRole('menuitem', { name: 'Show custom API' }).click() - await page.locator('input[name=api]').fill('https://walletd.local') + await page + .locator('input[name=api]') + .fill(process.env.WALLETD_E2E_TEST_API_URL) await page.locator('input[name=api]').press('Tab') - await page.locator('input[name=password]').fill('password') + await page + .locator('input[name=password]') + .fill(process.env.WALLETD_E2E_TEST_API_PASSWORD) await page.locator('input[name=password]').press('Enter') } diff --git a/libs/design-system/src/form/ConfigurationSwitch.tsx b/libs/design-system/src/form/ConfigurationSwitch.tsx index adb245ba1..9414544a5 100644 --- a/libs/design-system/src/form/ConfigurationSwitch.tsx +++ b/libs/design-system/src/form/ConfigurationSwitch.tsx @@ -20,6 +20,7 @@ export function ConfigurationSwitch<
+
{icon && ( diff --git a/server/Caddyfile-dev b/server/Caddyfile-dev index 0e39faec1..b4e4d2a77 100644 --- a/server/Caddyfile-dev +++ b/server/Caddyfile-dev @@ -6,6 +6,7 @@ # 127.0.0.1 zen.local # 127.0.0.1 host.local # 127.0.0.1 hostd.local +# 127.0.0.1 hostd.zen.local # 127.0.0.1 renter.local # 127.0.0.1 renterd.local # 127.0.0.1 renterd.zen.local @@ -126,4 +127,9 @@ walletd.local { renterd.zen.local { import cors reverse_proxy localhost:9880 -} \ No newline at end of file +} + +hostd.zen.local { + import cors + reverse_proxy localhost:9880 +}