diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 63d369cdf2..3d5d81ef65 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -167,6 +167,8 @@ jobs: needs: changes if: needs.changes.outputs.protofleet_e2e == 'true' uses: ./.github/workflows/protofleet-e2e-tests.yml + with: + run_scope: smoke secrets: inherit protoos-e2e-tests: diff --git a/.github/workflows/protofleet-e2e-tests.yml b/.github/workflows/protofleet-e2e-tests.yml index ade43da57e..d238512279 100644 --- a/.github/workflows/protofleet-e2e-tests.yml +++ b/.github/workflows/protofleet-e2e-tests.yml @@ -5,13 +5,29 @@ on: # Run daily at 6 AM UTC on main branch - cron: "0 6 * * *" workflow_dispatch: + inputs: + run_scope: + description: Which Proto Fleet E2E slice to run + required: false + default: all + type: choice + options: + - all + - smoke workflow_call: + inputs: + run_scope: + description: Which Proto Fleet E2E slice to run + required: false + default: smoke + type: string jobs: detect-specs: runs-on: ubuntu-latest outputs: specs: ${{ steps.detect.outputs.specs }} + target_tag: ${{ steps.detect.outputs.target_tag }} steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -20,16 +36,42 @@ jobs: - name: Detect spec files id: detect + env: + RUN_SCOPE: ${{ inputs.run_scope }} run: | cd client/e2eTests/protoFleet/spec + if [ -z "$RUN_SCOPE" ]; then + RUN_SCOPE="all" + fi + # Setup specs (NN-*.spec.ts) run automatically as Playwright project # dependencies in every shard. onboardingVisual.spec.ts runs in its # own fresh-env job, so only the remaining target specs are sharded. - SPEC_FILES=$( (find . -name "*.spec.ts" | sed 's|^\./||' | sort | grep -Ev '(^[0-9]{2}-.*\.spec\.ts$|^onboardingVisual\.spec\.ts$)' || true) | jq -R . | jq -cs .) + ALL_TARGET_SPECS=$(find . -name "*.spec.ts" | sed 's|^\./||' | sort | grep -Ev '(^[0-9]{2}-.*\.spec\.ts$|^onboardingVisual\.spec\.ts$)' || true) + + if [ "$RUN_SCOPE" = "smoke" ]; then + SPEC_FILES=$(printf '%s\n' "$ALL_TARGET_SPECS" | while IFS= read -r spec; do + if [ -n "$spec" ] && grep -q '@smoke' "$spec"; then + printf '%s\n' "$spec" + fi + done | jq -R . | jq -cs .) + TARGET_TAG="@smoke" + else + SPEC_FILES=$(printf '%s\n' "$ALL_TARGET_SPECS" | jq -R . | jq -cs .) + TARGET_TAG="" + fi + + if [ "$RUN_SCOPE" = "smoke" ] && [ "$SPEC_FILES" = "[]" ]; then + echo "No @smoke-tagged ProtoFleet specs were detected." + exit 1 + fi echo "specs=${SPEC_FILES}" >> "$GITHUB_OUTPUT" + echo "target_tag=${TARGET_TAG}" >> "$GITHUB_OUTPUT" + echo "Run scope: ${RUN_SCOPE}" echo "Detected specs: ${SPEC_FILES}" + echo "Target tag: ${TARGET_TAG:-}" build: name: Build client and plugins @@ -270,6 +312,7 @@ jobs: MATRIX_PROJECT: ${{ matrix.project }} MATRIX_SHARD: ${{ matrix.shard }} SPECS_JSON: ${{ needs.detect-specs.outputs.specs }} + TARGET_TAG: ${{ needs.detect-specs.outputs.target_tag }} run: | rm -rf blob-report playwright-report test-results mkdir -p blob-report @@ -297,9 +340,15 @@ jobs: echo "Specs for shard ${MATRIX_SHARD}:" printf ' - %s\n' "${SHARD_SPECS[@]}" + PLAYWRIGHT_ARGS=(--project="$MATRIX_PROJECT" --reporter=blob) + if [ -n "$TARGET_TAG" ]; then + PLAYWRIGHT_ARGS+=(--grep "$TARGET_TAG") + fi + PLAYWRIGHT_ARGS+=("${SHARD_SPECS[@]}") + PLAYWRIGHT_BLOB_OUTPUT_FILE="blob-report/${MATRIX_PROJECT}-shard-${MATRIX_SHARD}.zip" \ PWTEST_BLOB_DO_NOT_REMOVE=1 \ - npx playwright test --project="$MATRIX_PROJECT" --reporter=blob "${SHARD_SPECS[@]}" + npx playwright test "${PLAYWRIGHT_ARGS[@]}" echo "Blob reports created:" find blob-report -maxdepth 2 -type f -print | sort || true diff --git a/client/e2eTests/protoFleet/README.md b/client/e2eTests/protoFleet/README.md index 550cc25b1a..243e36776f 100644 --- a/client/e2eTests/protoFleet/README.md +++ b/client/e2eTests/protoFleet/README.md @@ -128,6 +128,9 @@ This script: The Playwright container reaches the host preview through `host.docker.internal`, so this flow is intended for Docker Desktop on macOS. If port `5173` is already in use, the script automatically falls back to the next free port in the `5173-5193` range. You can still force a specific port with `PREVIEW_PORT=5180`. +If the container ever shows a Vite page saying `Blocked request. This host ("host.docker.internal") is not allowed.`, +the preview server is rejecting the Docker hostname instead of serving Proto Fleet. This repo now allowlists that host +in `vite.config.ts`; if the error returns, verify the refresh flow is still using `vite preview` from this checkout. The script prefers the Playwright version from `client/node_modules`; if that is unavailable, it falls back to a cached copy from `~/.npm/_npx` and prints a warning if the version does not match `client/package.json`. diff --git a/client/e2eTests/protoFleet/spec/activity.spec.ts b/client/e2eTests/protoFleet/spec/activity.spec.ts index 45ce25fe0e..6cab06d9f1 100644 --- a/client/e2eTests/protoFleet/spec/activity.spec.ts +++ b/client/e2eTests/protoFleet/spec/activity.spec.ts @@ -42,47 +42,47 @@ test.describe("Proto Fleet - Activity", () => { } }); - test("Blink LEDs bulk action is visible in Activity with the right miner count", async ({ - activityPage, - commonSteps, - minersPage, - }) => { - await commonSteps.loginAsAdmin(); - await commonSteps.goToMinersPage(); + test( + "Blink LEDs bulk action is visible in Activity with the right miner count", + { tag: "@smoke" }, + async ({ activityPage, commonSteps, minersPage }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); - await test.step("Filter to Proto rig miners", async () => { - await minersPage.filterRigMiners(); - }); + await test.step("Filter to Proto rig miners", async () => { + await minersPage.filterRigMiners(); + }); - await test.step("Select three miners and trigger Blink LEDs", async () => { - await minersPage.clickMinerCheckboxByIndex(0); - await minersPage.validateActionBarMinerCount(1); - await minersPage.clickMinerCheckboxByIndex(1); - await minersPage.validateActionBarMinerCount(2); - await minersPage.clickMinerCheckboxByIndex(2); - await minersPage.validateActionBarMinerCount(3); + await test.step("Select three miners and trigger Blink LEDs", async () => { + await minersPage.clickMinerCheckboxByIndex(0); + await minersPage.validateActionBarMinerCount(1); + await minersPage.clickMinerCheckboxByIndex(1); + await minersPage.validateActionBarMinerCount(2); + await minersPage.clickMinerCheckboxByIndex(2); + await minersPage.validateActionBarMinerCount(3); - await minersPage.clickBlinkLEDsButton(); - }); + await minersPage.clickBlinkLEDsButton(); + }); - await test.step("Validate Blink LEDs toasts", async () => { - await minersPage.validateTextInToastGroup("Blinking LEDs"); - await minersPage.validateTextInToastGroup("Blinked LEDs"); - }); + await test.step("Validate Blink LEDs toasts", async () => { + await minersPage.validateTextInToastGroup("Blinking LEDs"); + await minersPage.validateTextInToastGroup("Blinked LEDs"); + }); - await test.step("Open Activity and filter by user", async () => { - await activityPage.navigateToActivityPage(); - await activityPage.waitForActivityListToLoad(); - await activityPage.selectUserFilter(testConfig.users.admin.username); - }); + await test.step("Open Activity and filter by user", async () => { + await activityPage.navigateToActivityPage(); + await activityPage.waitForActivityListToLoad(); + await activityPage.selectUserFilter(testConfig.users.admin.username); + }); - await test.step("Validate the latest activity row", async () => { - await activityPage.validateLatestActivityDescription("Blinked LEDs"); - await activityPage.validateLatestActivityScope("3 miners"); - await activityPage.validateLatestActivityUser(testConfig.users.admin.username); - await activityPage.validateLatestActivityNotMarkedFailed(); - }); - }); + await test.step("Validate the latest activity row", async () => { + await activityPage.validateLatestActivityDescription("Blinked LEDs"); + await activityPage.validateLatestActivityScope("3 miners"); + await activityPage.validateLatestActivityUser(testConfig.users.admin.username); + await activityPage.validateLatestActivityNotMarkedFailed(); + }); + }, + ); test("Blink LEDs activity detail modal shows batch summary", async ({ activityPage, commonSteps, minersPage }) => { await test.step("Trigger Blink LEDs for three Proto rig miners", async () => { @@ -127,40 +127,40 @@ test.describe("Proto Fleet - Activity", () => { }); }); - test("Type and user filter pills can be removed and Activity export starts a CSV download", async ({ - page, - activityPage, - commonSteps, - }) => { - await commonSteps.loginAsAdmin(); + test( + "Type and user filter pills can be removed and Activity export starts a CSV download", + { tag: "@smoke" }, + async ({ page, activityPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); - await test.step("Open Activity and apply type and user filters", async () => { - await activityPage.navigateToActivityPage(); - await activityPage.waitForActivityListToLoad(); - await activityPage.selectTypeFilter("Log in"); - await activityPage.selectUserFilter(testConfig.users.admin.username); - }); + await test.step("Open Activity and apply type and user filters", async () => { + await activityPage.navigateToActivityPage(); + await activityPage.waitForActivityListToLoad(); + await activityPage.selectTypeFilter("Log in"); + await activityPage.selectUserFilter(testConfig.users.admin.username); + }); - await test.step("Validate and remove the type filter pill", async () => { - await activityPage.validateFilterPillVisible("Log in"); - await activityPage.validateFilterPillVisible(testConfig.users.admin.username); - await activityPage.removeFilterPill("Log in"); - await activityPage.validateFilterPillNotVisible("Log in"); - await activityPage.validateFilterPillVisible(testConfig.users.admin.username); - await activityPage.validateLatestActivityUser(testConfig.users.admin.username); - }); + await test.step("Validate and remove the type filter pill", async () => { + await activityPage.validateFilterPillVisible("Log in"); + await activityPage.validateFilterPillVisible(testConfig.users.admin.username); + await activityPage.removeFilterPill("Log in"); + await activityPage.validateFilterPillNotVisible("Log in"); + await activityPage.validateFilterPillVisible(testConfig.users.admin.username); + await activityPage.validateLatestActivityUser(testConfig.users.admin.username); + }); - await test.step("Export the filtered activity list", async () => { - const download = await activityPage.exportCsv(); - test.expect(download.suggestedFilename()).toMatch(/activity-export.*\.csv$/i); - }); + await test.step("Export the filtered activity list", async () => { + const download = await activityPage.exportCsv(); + test.expect(download.suggestedFilename()).toMatch(/activity-export.*\.csv$/i); + }); - await test.step("Keep the list stable after export", async () => { - await page.bringToFront(); - await activityPage.waitForActivityListToLoad(); - await activityPage.validateLatestActivityUser(testConfig.users.admin.username); - }); - }); + await test.step("Keep the list stable after export", async () => { + await page.bringToFront(); + await activityPage.waitForActivityListToLoad(); + await activityPage.validateLatestActivityUser(testConfig.users.admin.username); + }); + }, + ); test("Scope filter pills can be removed for group activity", async ({ activityPage, commonSteps, groupsPage }) => { const groupName = generateRandomText("activity_group"); diff --git a/client/e2eTests/protoFleet/spec/activityLogin.spec.ts b/client/e2eTests/protoFleet/spec/activityLogin.spec.ts index b6711d32f5..3fc294ef4d 100644 --- a/client/e2eTests/protoFleet/spec/activityLogin.spec.ts +++ b/client/e2eTests/protoFleet/spec/activityLogin.spec.ts @@ -8,57 +8,58 @@ test.describe("Proto Fleet - Activity Login", () => { await page.goto("/"); }); - test("Failed login activity is visible after correcting invalid credentials and signing in", async ({ - authPage, - activityPage, - }) => { - await test.step("Log in as admin", async () => { - await authPage.inputUsername(testConfig.users.admin.username); - await authPage.inputPassword(testConfig.users.admin.password); - await authPage.clickLogin(); - await authPage.validateLoggedIn(); - }); + test( + "Failed login activity is visible after correcting invalid credentials and signing in", + { tag: "@smoke" }, + async ({ authPage, activityPage }) => { + await test.step("Log in as admin", async () => { + await authPage.inputUsername(testConfig.users.admin.username); + await authPage.inputPassword(testConfig.users.admin.password); + await authPage.clickLogin(); + await authPage.validateLoggedIn(); + }); - await test.step("Confirm the successful login activity is present before testing a failed login", async () => { - await activityPage.navigateToActivityPage(); - await activityPage.waitForActivityListToLoad(); - await activityPage.selectTypeFilter("Log in"); - await activityPage.selectUserFilter(testConfig.users.admin.username); - await activityPage.validateLatestActivityDescription("Logged in"); - await activityPage.validateLatestActivityUser(testConfig.users.admin.username); - await activityPage.validateLatestActivityNotMarkedFailed(); - }); + await test.step("Confirm the successful login activity is present before testing a failed login", async () => { + await activityPage.navigateToActivityPage(); + await activityPage.waitForActivityListToLoad(); + await activityPage.selectTypeFilter("Log in"); + await activityPage.selectUserFilter(testConfig.users.admin.username); + await activityPage.validateLatestActivityDescription("Logged in"); + await activityPage.validateLatestActivityUser(testConfig.users.admin.username); + await activityPage.validateLatestActivityNotMarkedFailed(); + }); - await test.step("Log out", async () => { - await authPage.logout(); - await authPage.validateRedirectedToAuth(); - }); + await test.step("Log out", async () => { + await authPage.logout(); + await authPage.validateRedirectedToAuth(); + }); - await test.step("Attempt login with an invalid password and validate the error", async () => { - await authPage.inputUsername(testConfig.users.admin.username); - await authPage.inputPassword(`${testConfig.users.admin.password}-invalid`); - await authPage.clickLogin(); - await authPage.validateInvalidCredentials(); - }); + await test.step("Attempt login with an invalid password and validate the error", async () => { + await authPage.inputUsername(testConfig.users.admin.username); + await authPage.inputPassword(`${testConfig.users.admin.password}-invalid`); + await authPage.clickLogin(); + await authPage.validateInvalidCredentials(); + }); - await test.step("Rewrite the correct password and validate the error clears", async () => { - await authPage.inputPassword(testConfig.users.admin.password); - await authPage.validateInvalidCredentialsNotVisible(); - }); + await test.step("Rewrite the correct password and validate the error clears", async () => { + await authPage.inputPassword(testConfig.users.admin.password); + await authPage.validateInvalidCredentialsNotVisible(); + }); - await test.step("Log in successfully with corrected credentials", async () => { - await authPage.clickLogin(); - await authPage.validateLoggedIn(); - }); + await test.step("Log in successfully with corrected credentials", async () => { + await authPage.clickLogin(); + await authPage.validateLoggedIn(); + }); - await test.step("Validate the failed login attempt appears in Activity", async () => { - await activityPage.navigateToActivityPage(); - await activityPage.waitForActivityListToLoad(); - await activityPage.searchActivity("Login failed"); - await activityPage.selectUserFilter(testConfig.users.admin.username); - await activityPage.validateLatestActivityDescription("Couldn't log in"); - await activityPage.validateLatestActivityUser(testConfig.users.admin.username); - await activityPage.validateLatestActivityMarkedFailed(); - }); - }); + await test.step("Validate the failed login attempt appears in Activity", async () => { + await activityPage.navigateToActivityPage(); + await activityPage.waitForActivityListToLoad(); + await activityPage.searchActivity("Login failed"); + await activityPage.selectUserFilter(testConfig.users.admin.username); + await activityPage.validateLatestActivityDescription("Couldn't log in"); + await activityPage.validateLatestActivityUser(testConfig.users.admin.username); + await activityPage.validateLatestActivityMarkedFailed(); + }); + }, + ); }); diff --git a/client/e2eTests/protoFleet/spec/addMinersValidation.spec.ts b/client/e2eTests/protoFleet/spec/addMinersValidation.spec.ts index 8ba1f023b1..c30363a7e4 100644 --- a/client/e2eTests/protoFleet/spec/addMinersValidation.spec.ts +++ b/client/e2eTests/protoFleet/spec/addMinersValidation.spec.ts @@ -6,68 +6,76 @@ test.describe("Proto Fleet - Add Miners Validation", () => { await commonSteps.loginAsAdmin(); }); - test("Back to editing button closes dialog and returns to form", async ({ minersPage, addMinersPage }) => { - await test.step("Navigate to add miners flow", async () => { - await minersPage.navigateToMinersPage(); - await minersPage.clickAddMinersButton(); - }); - - await test.step("Enter mix of valid and invalid entries", async () => { - await addMinersPage.inputMinerIp("192.168.1.1, 999.999.999.999"); - await addMinersPage.clickFindMinersByIp(); - }); - - await test.step("Validate error dialog is shown", async () => { - await addMinersPage.validateValidationErrorDialogIsVisible(); - }); - - await test.step("Click back to editing", async () => { - await addMinersPage.clickBackToEditing(); - }); - - await test.step("Validate dialog is closed and form is still visible", async () => { - await addMinersPage.validateValidationErrorDialogIsClosed(); - // Verify the textarea is still accessible with the original value - const textarea = addMinersPage["page"].locator("#ipAddresses"); - await expect(textarea).toBeVisible(); - }); - - await test.step("Validate error message appears on textarea", async () => { - await addMinersPage.validateTextareaErrorContains("Check the format of the following and retry"); - await addMinersPage.validateTextareaErrorContains("999.999.999.999"); - }); - }); - - test("Continue anyway button proceeds with valid entries only", async ({ minersPage, addMinersPage, page }) => { - await test.step("Navigate to add miners flow", async () => { - await minersPage.navigateToMinersPage(); - await minersPage.clickAddMinersButton(); - }); - - await test.step("Enter mix of valid and invalid entries", async () => { - await addMinersPage.inputMinerIp("192.168.1.1, 999.999.999.999"); - await addMinersPage.clickFindMinersByIp(); - }); - - await test.step("Validate error dialog is shown", async () => { - await addMinersPage.validateValidationErrorDialogIsVisible(); - }); - - await test.step("Click continue anyway", async () => { - await addMinersPage.clickContinueAnyway(); - }); - - await test.step("Validate dialog is closed and discovery proceeds", async () => { - await addMinersPage.validateValidationErrorDialogIsClosed(); - // The pairing step should now be active (either loading or showing results) - const findingMinersTitle = page.getByText("Finding miners on your network"); - const foundMinersSection = page.getByText(/\d+ miners found/); - const noMinersFound = page.getByText(/No miners found/); - - // Wait for either the loading state, results, or no miners found - await expect(findingMinersTitle.or(foundMinersSection).or(noMinersFound)).toBeVisible({ timeout: 10000 }); - }); - }); + test( + "Back to editing button closes dialog and returns to form", + { tag: "@smoke" }, + async ({ minersPage, addMinersPage }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter mix of valid and invalid entries", async () => { + await addMinersPage.inputMinerIp("192.168.1.1, 999.999.999.999"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate error dialog is shown", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + }); + + await test.step("Click back to editing", async () => { + await addMinersPage.clickBackToEditing(); + }); + + await test.step("Validate dialog is closed and form is still visible", async () => { + await addMinersPage.validateValidationErrorDialogIsClosed(); + // Verify the textarea is still accessible with the original value + const textarea = addMinersPage["page"].locator("#ipAddresses"); + await expect(textarea).toBeVisible(); + }); + + await test.step("Validate error message appears on textarea", async () => { + await addMinersPage.validateTextareaErrorContains("Check the format of the following and retry"); + await addMinersPage.validateTextareaErrorContains("999.999.999.999"); + }); + }, + ); + + test( + "Continue anyway button proceeds with valid entries only", + { tag: "@smoke" }, + async ({ minersPage, addMinersPage, page }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter mix of valid and invalid entries", async () => { + await addMinersPage.inputMinerIp("192.168.1.1, 999.999.999.999"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate error dialog is shown", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + }); + + await test.step("Click continue anyway", async () => { + await addMinersPage.clickContinueAnyway(); + }); + + await test.step("Validate dialog is closed and discovery proceeds", async () => { + await addMinersPage.validateValidationErrorDialogIsClosed(); + // The pairing step should now be active (either loading or showing results) + const findingMinersTitle = page.getByText("Finding miners on your network"); + const foundMinersSection = page.getByText(/\d+ miners found/); + const noMinersFound = page.getByText(/No miners found/); + + // Wait for either the loading state, results, or no miners found + await expect(findingMinersTitle.or(foundMinersSection).or(noMinersFound)).toBeVisible({ timeout: 10000 }); + }); + }, + ); test("Shows multiple error categories in dialog", async ({ minersPage, addMinersPage }) => { await test.step("Navigate to add miners flow", async () => { diff --git a/client/e2eTests/protoFleet/spec/apiKeysSettings.spec.ts b/client/e2eTests/protoFleet/spec/apiKeysSettings.spec.ts index df21b6105d..7a65647fb1 100644 --- a/client/e2eTests/protoFleet/spec/apiKeysSettings.spec.ts +++ b/client/e2eTests/protoFleet/spec/apiKeysSettings.spec.ts @@ -42,7 +42,7 @@ test.describe("Proto Fleet - Integrations", () => { } }); - test("Create and revoke API key", async ({ commonSteps, settingsApiKeysPage }) => { + test("Create and revoke API key", { tag: "@smoke" }, async ({ commonSteps, settingsApiKeysPage }) => { const apiKeyName = generateRandomText(API_KEY_PREFIX); await test.step("Log in as admin", async () => { diff --git a/client/e2eTests/protoFleet/spec/auth.spec.ts b/client/e2eTests/protoFleet/spec/auth.spec.ts index 0a168b216f..9980026c65 100644 --- a/client/e2eTests/protoFleet/spec/auth.spec.ts +++ b/client/e2eTests/protoFleet/spec/auth.spec.ts @@ -9,7 +9,7 @@ test.describe("Proto Fleet - Authentication", () => { await page.goto("/"); }); - test("Sign in with admin", async ({ authPage, settingsPage, settingsTeamPage }) => { + test("Sign in with admin", { tag: "@smoke" }, async ({ authPage, settingsPage, settingsTeamPage }) => { await test.step("Log in as admin user", async () => { await authPage.inputUsername(testConfig.users.admin.username); await authPage.inputPassword(testConfig.users.admin.password); diff --git a/client/e2eTests/protoFleet/spec/buildingDetail.spec.ts b/client/e2eTests/protoFleet/spec/buildingDetail.spec.ts index 49c02a102f..3e7cab70c0 100644 --- a/client/e2eTests/protoFleet/spec/buildingDetail.spec.ts +++ b/client/e2eTests/protoFleet/spec/buildingDetail.spec.ts @@ -11,43 +11,42 @@ import { validateRackAndMinerPlacementAcrossTabs, validateSiteAndBuildingCounts test.describe("Buildings - detail", () => { useBuildingDetailHooks(); - test("Building detail supports editing details, opening scoped racks and miners, and switching to a sibling building", async ({ - page, - fleetLocationsPage, - minersPage, - racksPage, - }, testInfo) => { - const scenario = createBuildingDetailScenarioData(testInfo); - const { selectedMinerIps } = await setupBuildingDetailScenario(page, fleetLocationsPage, racksPage, scenario); + test( + "Building detail supports editing details, opening scoped racks and miners, and switching to a sibling building", + { tag: "@smoke" }, + async ({ page, fleetLocationsPage, minersPage, racksPage }, testInfo) => { + const scenario = createBuildingDetailScenarioData(testInfo); + const { selectedMinerIps } = await setupBuildingDetailScenario(page, fleetLocationsPage, racksPage, scenario); - await fleetLocationsPage.openBuildingDetail(scenario.buildingName); - await fleetLocationsPage.validateBuildingDetailOpened(scenario.buildingName); - await fleetLocationsPage.validateBuildingDetailMetrics({ totalMiners: 2 }); + await fleetLocationsPage.openBuildingDetail(scenario.buildingName); + await fleetLocationsPage.validateBuildingDetailOpened(scenario.buildingName); + await fleetLocationsPage.validateBuildingDetailMetrics({ totalMiners: 2 }); - await fleetLocationsPage.editBuildingDetailsFromDetail({ name: scenario.renamedBuildingName }); + await fleetLocationsPage.editBuildingDetailsFromDetail({ name: scenario.renamedBuildingName }); - await fleetLocationsPage.validateBuildingDetailOpened(scenario.renamedBuildingName); - await fleetLocationsPage.validateBuildingDetailMetrics({ totalMiners: 2 }); + await fleetLocationsPage.validateBuildingDetailOpened(scenario.renamedBuildingName); + await fleetLocationsPage.validateBuildingDetailMetrics({ totalMiners: 2 }); - await validateBuildingDetailScenarioAcrossTabs({ - page, - fleetLocationsPage, - minersPage, - racksPage, - scenario: { - siteName: scenario.siteName, - buildingName: scenario.renamedBuildingName, - siblingBuildingName: scenario.siblingBuildingName, - rackLabel: scenario.rackLabel, - }, - selectedMinerIps, - }); + await validateBuildingDetailScenarioAcrossTabs({ + page, + fleetLocationsPage, + minersPage, + racksPage, + scenario: { + siteName: scenario.siteName, + buildingName: scenario.renamedBuildingName, + siblingBuildingName: scenario.siblingBuildingName, + rackLabel: scenario.rackLabel, + }, + selectedMinerIps, + }); - await fleetLocationsPage.openBuildingDetail(scenario.renamedBuildingName); - await fleetLocationsPage.switchBuildingDetailBreadcrumbTo(scenario.siblingBuildingName); - await fleetLocationsPage.validateBuildingDetailOpened(scenario.siblingBuildingName); - await fleetLocationsPage.validateBuildingDetailMetrics({ minersOnline: "0 / 0" }); - }); + await fleetLocationsPage.openBuildingDetail(scenario.renamedBuildingName); + await fleetLocationsPage.switchBuildingDetailBreadcrumbTo(scenario.siblingBuildingName); + await fleetLocationsPage.validateBuildingDetailOpened(scenario.siblingBuildingName); + await fleetLocationsPage.validateBuildingDetailMetrics({ minersOnline: "0 / 0" }); + }, + ); test("Deleting a building from the detail page keeps the rack on the site", async ({ page, diff --git a/client/e2eTests/protoFleet/spec/buildings.spec.ts b/client/e2eTests/protoFleet/spec/buildings.spec.ts index 9ba979f766..4313995503 100644 --- a/client/e2eTests/protoFleet/spec/buildings.spec.ts +++ b/client/e2eTests/protoFleet/spec/buildings.spec.ts @@ -14,26 +14,25 @@ import { test.describe("Buildings", () => { useBuildingsHooks(); - test("Create a site, building, rack, and miners flow across fleet tabs", async ({ - page, - fleetLocationsPage, - minersPage, - racksPage, - }) => { - const scenario = createBuildingsScenarioData(); - const buildingId = await createSiteAndBuilding(fleetLocationsPage, scenario); - const { rackId, selectedMinerIps } = await createRackWithAssignedMiners(racksPage, scenario.rackLabel); + test( + "Create a site, building, rack, and miners flow across fleet tabs", + { tag: "@smoke" }, + async ({ page, fleetLocationsPage, minersPage, racksPage }) => { + const scenario = createBuildingsScenarioData(); + const buildingId = await createSiteAndBuilding(fleetLocationsPage, scenario); + const { rackId, selectedMinerIps } = await createRackWithAssignedMiners(racksPage, scenario.rackLabel); - await assignRackToBuilding(page, racksPage, scenario.rackLabel, rackId, scenario.buildingName, buildingId); - await validateBuildingPlacementAcrossTabs({ - page, - fleetLocationsPage, - minersPage, - racksPage, - scenario, - selectedMinerIps, - }); - }); + await assignRackToBuilding(page, racksPage, scenario.rackLabel, rackId, scenario.buildingName, buildingId); + await validateBuildingPlacementAcrossTabs({ + page, + fleetLocationsPage, + minersPage, + racksPage, + scenario, + selectedMinerIps, + }); + }, + ); test("Move a rack between buildings and then unassign it", async ({ page, @@ -100,39 +99,38 @@ test.describe("Buildings", () => { }); }); - test("Rename a building and propagate the new name across fleet tabs", async ({ - page, - fleetLocationsPage, - minersPage, - racksPage, - }) => { - const scenario = createBuildingsScenarioData(); - const renamedBuilding = createBuildingsScenarioData().buildingName; - const buildingId = await createSiteAndBuilding(fleetLocationsPage, scenario); - const { rackId, selectedMinerIps } = await createRackWithAssignedMiners(racksPage, scenario.rackLabel); + test( + "Rename a building and propagate the new name across fleet tabs", + { tag: "@smoke" }, + async ({ page, fleetLocationsPage, minersPage, racksPage }) => { + const scenario = createBuildingsScenarioData(); + const renamedBuilding = createBuildingsScenarioData().buildingName; + const buildingId = await createSiteAndBuilding(fleetLocationsPage, scenario); + const { rackId, selectedMinerIps } = await createRackWithAssignedMiners(racksPage, scenario.rackLabel); - await assignRackToBuilding(page, racksPage, scenario.rackLabel, rackId, scenario.buildingName, buildingId); - await fleetLocationsPage.renameBuilding(scenario.buildingName, renamedBuilding); + await assignRackToBuilding(page, racksPage, scenario.rackLabel, rackId, scenario.buildingName, buildingId); + await fleetLocationsPage.renameBuilding(scenario.buildingName, renamedBuilding); - await validateSiteAndBuildingCounts(fleetLocationsPage, { - siteName: scenario.siteName, - siteCounts: { - buildings: 1, - racks: 1, - miners: 2, - }, - buildings: [{ buildingName: renamedBuilding, racks: 1, miners: 2 }], - }); - await validateRackAndMinerPlacementAcrossTabs({ - page, - minersPage, - racksPage, - siteName: scenario.siteName, - buildingName: renamedBuilding, - rackLabel: scenario.rackLabel, - selectedMinerIps, - }); - }); + await validateSiteAndBuildingCounts(fleetLocationsPage, { + siteName: scenario.siteName, + siteCounts: { + buildings: 1, + racks: 1, + miners: 2, + }, + buildings: [{ buildingName: renamedBuilding, racks: 1, miners: 2 }], + }); + await validateRackAndMinerPlacementAcrossTabs({ + page, + minersPage, + racksPage, + siteName: scenario.siteName, + buildingName: renamedBuilding, + rackLabel: scenario.rackLabel, + selectedMinerIps, + }); + }, + ); test("Delete a building with an assigned rack and keep the rack on the site", async ({ page, diff --git a/client/e2eTests/protoFleet/spec/curtailment.spec.ts b/client/e2eTests/protoFleet/spec/curtailment.spec.ts index 46e52e3409..3ab025e63d 100644 --- a/client/e2eTests/protoFleet/spec/curtailment.spec.ts +++ b/client/e2eTests/protoFleet/spec/curtailment.spec.ts @@ -26,7 +26,7 @@ test.describe("Proto Fleet - Curtailment", () => { }); if (testConfig.target !== "real") { - test("Start and stop a whole-fleet curtailment", async ({ commonSteps, energyPage, page }) => { + test("Start and stop a whole-fleet curtailment", { tag: "@smoke" }, async ({ commonSteps, energyPage, page }) => { test.setTimeout(testConfig.testTimeout * 2); const curtailmentReason = generateRandomText(CURTAILMENT_PREFIX); diff --git a/client/e2eTests/protoFleet/spec/curtailmentSettings.spec.ts b/client/e2eTests/protoFleet/spec/curtailmentSettings.spec.ts index 7b86b73966..fe50eb4e29 100644 --- a/client/e2eTests/protoFleet/spec/curtailmentSettings.spec.ts +++ b/client/e2eTests/protoFleet/spec/curtailmentSettings.spec.ts @@ -150,100 +150,100 @@ test.describe("Proto Fleet - Curtailment Settings", () => { } }); - test("Create curtailment response profiles and sources", async ({ - commonSteps, - page, - settingsCurtailmentPage, - }, testInfo) => { - const { responseProfilePrefix, sourcePrefix } = getRunPrefixes(testInfo); - const responseProfileName = generateRandomText(responseProfilePrefix); - const sourceName = generateRandomText(sourcePrefix); - const sourceInput = { - name: sourceName, - brokerPrimaryHost: "127.0.0.1", - brokerSecondaryHost: "127.0.0.2", - brokerPort: "1883", - topic: `curtailment/e2e/${sourceName}/target`, - username: "curtailment-e2e", - password: "curtailment-e2e-password", - }; - - await test.step("Log in as admin", async () => { - await commonSteps.loginAsAdmin(); - }); - - await test.step("Navigate to curtailment settings", async () => { - await settingsCurtailmentPage.navigateToCurtailmentSettings(); - await settingsCurtailmentPage.validateCurtailmentPageOpened(); - }); - - let createProfileRequest!: Awaited>; - - await test.step("Create a whole-fleet response profile", async () => { - await settingsCurtailmentPage.openCreateResponseProfile(); - await settingsCurtailmentPage.fillResponseProfile({ - name: responseProfileName, - curtailBatchSize: "25", - curtailBatchIntervalSec: "60", - restoreBatchSize: "10", - restoreBatchIntervalSec: "120", + test( + "Create curtailment response profiles and sources", + { tag: "@smoke" }, + async ({ commonSteps, page, settingsCurtailmentPage }, testInfo) => { + const { responseProfilePrefix, sourcePrefix } = getRunPrefixes(testInfo); + const responseProfileName = generateRandomText(responseProfilePrefix); + const sourceName = generateRandomText(sourcePrefix); + const sourceInput = { + name: sourceName, + brokerPrimaryHost: "127.0.0.1", + brokerSecondaryHost: "127.0.0.2", + brokerPort: "1883", + topic: `curtailment/e2e/${sourceName}/target`, + username: "curtailment-e2e", + password: "curtailment-e2e-password", + }; + + await test.step("Log in as admin", async () => { + await commonSteps.loginAsAdmin(); }); - [createProfileRequest] = await Promise.all([ - page.waitForRequest((request) => isCreateResponseProfileRequest(request, responseProfileName)), - settingsCurtailmentPage.saveResponseProfile(), - ]); - }); - - await test.step("Validate the response profile payload and card", async () => { - const requestBody = createProfileRequest.postDataJSON() as CreateResponseProfileRequestBody; - - expect(createProfileRequest.method()).toBe("POST"); - expect(requestBody.profileName).toBe(responseProfileName); - expect(requestBody.mode).toBe("CURTAILMENT_MODE_FULL_FLEET"); - expect(requestBody.strategy).toBe("CURTAILMENT_STRATEGY_LEAST_EFFICIENT_FIRST"); - expect(requestBody.level).toBe("CURTAILMENT_LEVEL_FULL"); - expect(requestBody.priority).toBe("CURTAILMENT_PRIORITY_NORMAL"); - expect(requestBody.curtailBatchSize).toBe(25); - expect(requestBody.curtailBatchIntervalSec).toBe(60); - expect(requestBody.restoreBatchSize).toBe(10); - expect(requestBody.restoreBatchIntervalSec).toBe(120); - // Maintenance-flagged miners are excluded by default; the admin-gated - // force_include_maintenance pair is only sent when "Target all paired - // miners" opts them in. Proto3 JSON omits false booleans, so assert - // falsy rather than an explicit false. - expect(requestBody.includeMaintenance).toBeFalsy(); - expect(requestBody.forceIncludeMaintenance).toBeFalsy(); - await settingsCurtailmentPage.validateResponseProfileVisible(responseProfileName); - }); - - let createSourceRequest!: Awaited>; - - await test.step("Create an MQTT curtailment source", async () => { - await settingsCurtailmentPage.openAddSource(); - await settingsCurtailmentPage.fillSource(sourceInput); - - [createSourceRequest] = await Promise.all([ - page.waitForRequest((request) => isCreateSourceRequest(request, sourceName)), - settingsCurtailmentPage.saveSource(), - ]); - }); - - await test.step("Validate the source payload and row", async () => { - const requestBody = createSourceRequest.postDataJSON() as CreateSourceRequestBody; - - expect(createSourceRequest.method()).toBe("POST"); - expect(requestBody.sourceName).toBe(sourceName); - expect(requestBody.topic).toBe(sourceInput.topic); - expect(requestBody.brokerPrimaryHost).toBe(sourceInput.brokerPrimaryHost); - expect(requestBody.brokerSecondaryHost).toBe(sourceInput.brokerSecondaryHost); - expect(requestBody.brokerPort).toBe(Number(sourceInput.brokerPort)); - expect(requestBody.brokerTransport).toBe("tcp"); - expect(requestBody.mqttUsername).toBe(sourceInput.username); - expect(requestBody.mqttPassword).toBe(sourceInput.password); - expect(requestBody.payloadFormat).toBe("target_timestamp"); - expect(requestBody.stalenessThresholdSec).toBe(240); - await settingsCurtailmentPage.validateSourceVisible(sourceName); - }); - }); + await test.step("Navigate to curtailment settings", async () => { + await settingsCurtailmentPage.navigateToCurtailmentSettings(); + await settingsCurtailmentPage.validateCurtailmentPageOpened(); + }); + + let createProfileRequest!: Awaited>; + + await test.step("Create a whole-fleet response profile", async () => { + await settingsCurtailmentPage.openCreateResponseProfile(); + await settingsCurtailmentPage.fillResponseProfile({ + name: responseProfileName, + curtailBatchSize: "25", + curtailBatchIntervalSec: "60", + restoreBatchSize: "10", + restoreBatchIntervalSec: "120", + }); + + [createProfileRequest] = await Promise.all([ + page.waitForRequest((request) => isCreateResponseProfileRequest(request, responseProfileName)), + settingsCurtailmentPage.saveResponseProfile(), + ]); + }); + + await test.step("Validate the response profile payload and card", async () => { + const requestBody = createProfileRequest.postDataJSON() as CreateResponseProfileRequestBody; + + expect(createProfileRequest.method()).toBe("POST"); + expect(requestBody.profileName).toBe(responseProfileName); + expect(requestBody.mode).toBe("CURTAILMENT_MODE_FULL_FLEET"); + expect(requestBody.strategy).toBe("CURTAILMENT_STRATEGY_LEAST_EFFICIENT_FIRST"); + expect(requestBody.level).toBe("CURTAILMENT_LEVEL_FULL"); + expect(requestBody.priority).toBe("CURTAILMENT_PRIORITY_NORMAL"); + expect(requestBody.curtailBatchSize).toBe(25); + expect(requestBody.curtailBatchIntervalSec).toBe(60); + expect(requestBody.restoreBatchSize).toBe(10); + expect(requestBody.restoreBatchIntervalSec).toBe(120); + // Maintenance-flagged miners are excluded by default; the admin-gated + // force_include_maintenance pair is only sent when "Target all paired + // miners" opts them in. Proto3 JSON omits false booleans, so assert + // falsy rather than an explicit false. + expect(requestBody.includeMaintenance).toBeFalsy(); + expect(requestBody.forceIncludeMaintenance).toBeFalsy(); + await settingsCurtailmentPage.validateResponseProfileVisible(responseProfileName); + }); + + let createSourceRequest!: Awaited>; + + await test.step("Create an MQTT curtailment source", async () => { + await settingsCurtailmentPage.openAddSource(); + await settingsCurtailmentPage.fillSource(sourceInput); + + [createSourceRequest] = await Promise.all([ + page.waitForRequest((request) => isCreateSourceRequest(request, sourceName)), + settingsCurtailmentPage.saveSource(), + ]); + }); + + await test.step("Validate the source payload and row", async () => { + const requestBody = createSourceRequest.postDataJSON() as CreateSourceRequestBody; + + expect(createSourceRequest.method()).toBe("POST"); + expect(requestBody.sourceName).toBe(sourceName); + expect(requestBody.topic).toBe(sourceInput.topic); + expect(requestBody.brokerPrimaryHost).toBe(sourceInput.brokerPrimaryHost); + expect(requestBody.brokerSecondaryHost).toBe(sourceInput.brokerSecondaryHost); + expect(requestBody.brokerPort).toBe(Number(sourceInput.brokerPort)); + expect(requestBody.brokerTransport).toBe("tcp"); + expect(requestBody.mqttUsername).toBe(sourceInput.username); + expect(requestBody.mqttPassword).toBe(sourceInput.password); + expect(requestBody.payloadFormat).toBe("target_timestamp"); + expect(requestBody.stalenessThresholdSec).toBe(240); + await settingsCurtailmentPage.validateSourceVisible(sourceName); + }); + }, + ); }); diff --git a/client/e2eTests/protoFleet/spec/dashboard.spec.ts b/client/e2eTests/protoFleet/spec/dashboard.spec.ts index 36e568fa68..6a371eb9cd 100644 --- a/client/e2eTests/protoFleet/spec/dashboard.spec.ts +++ b/client/e2eTests/protoFleet/spec/dashboard.spec.ts @@ -13,7 +13,7 @@ test.describe("Proto Fleet - Dashboard", () => { await page.goto("/"); }); - test("Dashboard renders the paired fleet shell", async ({ homePage, commonSteps }) => { + test("Dashboard renders the paired fleet shell", { tag: "@smoke" }, async ({ homePage, commonSteps }) => { await commonSteps.loginAsAdmin(); await test.step("Validate dashboard sections are visible", async () => { diff --git a/client/e2eTests/protoFleet/spec/firmware.spec.ts b/client/e2eTests/protoFleet/spec/firmware.spec.ts index 1b334753ad..8e4227c1f8 100644 --- a/client/e2eTests/protoFleet/spec/firmware.spec.ts +++ b/client/e2eTests/protoFleet/spec/firmware.spec.ts @@ -90,7 +90,7 @@ test.describe("Firmware", () => { }, ); - test("Upload firmware and update a rig miner", async ({ minersPage, settingsFirmwarePage }) => { + test("Upload firmware and update a rig miner", { tag: "@smoke" }, async ({ minersPage, settingsFirmwarePage }) => { test.setTimeout(testConfig.testTimeout * 4); const firmwareVersion = "2.4.6"; diff --git a/client/e2eTests/protoFleet/spec/fleetFilters.spec.ts b/client/e2eTests/protoFleet/spec/fleetFilters.spec.ts index 88c76bb201..02ade75cd3 100644 --- a/client/e2eTests/protoFleet/spec/fleetFilters.spec.ts +++ b/client/e2eTests/protoFleet/spec/fleetFilters.spec.ts @@ -9,113 +9,117 @@ import { test.describe("Proto Fleet - Fleet filters", () => { useBuildingsHooks(); - test("Buildings site filter scopes the list and clears cleanly", async ({ page, fleetLocationsPage }) => { - const primary = createBuildingsScenarioData(); - const secondary = createBuildingsScenarioData(); + test( + "Buildings site filter scopes the list and clears cleanly", + { tag: "@smoke" }, + async ({ page, fleetLocationsPage }) => { + const primary = createBuildingsScenarioData(); + const secondary = createBuildingsScenarioData(); + + await createSiteAndBuilding(fleetLocationsPage, primary); + await createSiteAndBuilding(fleetLocationsPage, secondary); + + const primarySiteId = await fleetLocationsPage.getSiteIdByName(primary.siteName); + const secondarySiteId = await fleetLocationsPage.getSiteIdByName(secondary.siteName); + + await test.step("Apply the first site filter from the buildings tab", async () => { + await fleetLocationsPage.navigateToBuildingsPage(); + await fleetLocationsPage.applySiteFilter([primary.siteName]); + + await fleetLocationsPage.validateActiveFilterSummary("site", primary.siteName); + await fleetLocationsPage.validateCurrentBuildingRowCounts(primary.buildingName, { + siteName: primary.siteName, + racks: 0, + miners: 0, + }); + await fleetLocationsPage.validateCurrentBuildingNotVisible(secondary.buildingName); + + test.expect(new URL(page.url()).searchParams.getAll("site")).toEqual([primarySiteId.toString()]); + }); + + await test.step("Switch the filter to the second site", async () => { + await fleetLocationsPage.applySiteFilter([secondary.siteName]); + + await fleetLocationsPage.validateActiveFilterSummary("site", secondary.siteName); + await fleetLocationsPage.validateCurrentBuildingRowCounts(secondary.buildingName, { + siteName: secondary.siteName, + racks: 0, + miners: 0, + }); + await fleetLocationsPage.validateCurrentBuildingNotVisible(primary.buildingName); + + test.expect(new URL(page.url()).searchParams.getAll("site")).toEqual([secondarySiteId.toString()]); + }); - await createSiteAndBuilding(fleetLocationsPage, primary); - await createSiteAndBuilding(fleetLocationsPage, secondary); + await test.step("Clear the site filter and show both buildings again", async () => { + await fleetLocationsPage.clearActiveFilter("site"); + + await fleetLocationsPage.validateActiveFilterNotVisible("Sites"); + await fleetLocationsPage.validateCurrentBuildingVisible(primary.buildingName); + await fleetLocationsPage.validateCurrentBuildingVisible(secondary.buildingName); + + test.expect(new URL(page.url()).searchParams.getAll("site")).toEqual([]); + }); + }, + ); + + test( + "Racks site and building filters can reach no results and then clear cleanly", + { tag: "@smoke" }, + async ({ page, fleetLocationsPage, racksPage }) => { + const primary = createBuildingsScenarioData(); + const secondary = createBuildingsScenarioData(); + + await setupRackAssignedToBuilding(page, fleetLocationsPage, racksPage, primary); + const { buildingId: secondaryBuildingId } = await setupRackAssignedToBuilding( + page, + fleetLocationsPage, + racksPage, + secondary, + ); + + const primarySiteId = await fleetLocationsPage.getSiteIdByName(primary.siteName); + + await test.step("Apply a site filter and confirm only the matching rack remains", async () => { + await racksPage.navigateToRacksPage(); + await racksPage.clickViewList(); + await racksPage.waitForRackListToLoad({ allowEmpty: false }); + + await racksPage.applySiteFilter([primary.siteName]); + await racksPage.waitForRackListToLoad({ allowEmpty: false }); + + await racksPage.validateActiveFilterSummary("site", primary.siteName); + await racksPage.validateRackPlacementRow(primary.rackLabel, primary.siteName, primary.buildingName); + await racksPage.validateRackNotVisible(secondary.rackLabel); + + test.expect(new URL(page.url()).searchParams.getAll("site")).toEqual([primarySiteId.toString()]); + }); - const primarySiteId = await fleetLocationsPage.getSiteIdByName(primary.siteName); - const secondarySiteId = await fleetLocationsPage.getSiteIdByName(secondary.siteName); + await test.step("Add a mismatched building filter to force the no-results state", async () => { + await racksPage.applyBuildingFilter([secondary.buildingName]); + await racksPage.waitForRackListToLoad(); - await test.step("Apply the first site filter from the buildings tab", async () => { - await fleetLocationsPage.navigateToBuildingsPage(); - await fleetLocationsPage.applySiteFilter([primary.siteName]); + await racksPage.validateActiveFilterSummary("building", secondary.buildingName); + await racksPage.validateNoResultsEmptyState(); - await fleetLocationsPage.validateActiveFilterSummary("site", primary.siteName); - await fleetLocationsPage.validateCurrentBuildingRowCounts(primary.buildingName, { - siteName: primary.siteName, - racks: 0, - miners: 0, + const searchParams = new URL(page.url()).searchParams; + test.expect(searchParams.getAll("site")).toEqual([primarySiteId.toString()]); + test.expect(searchParams.getAll("building")).toEqual([secondaryBuildingId.toString()]); }); - await fleetLocationsPage.validateCurrentBuildingNotVisible(secondary.buildingName); - test.expect(new URL(page.url()).searchParams.getAll("site")).toEqual([primarySiteId.toString()]); - }); + await test.step("Clear all filters and restore the full rack list", async () => { + await racksPage.clickClearAllFilters(); + await racksPage.waitForRackListToLoad({ allowEmpty: false }); - await test.step("Switch the filter to the second site", async () => { - await fleetLocationsPage.applySiteFilter([secondary.siteName]); + await racksPage.validateActiveFilterNotVisible("Sites"); + await racksPage.validateActiveFilterNotVisible("Buildings"); + await racksPage.validateRackPlacementRow(primary.rackLabel, primary.siteName, primary.buildingName); + await racksPage.validateRackPlacementRow(secondary.rackLabel, secondary.siteName, secondary.buildingName); - await fleetLocationsPage.validateActiveFilterSummary("site", secondary.siteName); - await fleetLocationsPage.validateCurrentBuildingRowCounts(secondary.buildingName, { - siteName: secondary.siteName, - racks: 0, - miners: 0, + const searchParams = new URL(page.url()).searchParams; + test.expect(searchParams.getAll("site")).toEqual([]); + test.expect(searchParams.getAll("building")).toEqual([]); }); - await fleetLocationsPage.validateCurrentBuildingNotVisible(primary.buildingName); - - test.expect(new URL(page.url()).searchParams.getAll("site")).toEqual([secondarySiteId.toString()]); - }); - - await test.step("Clear the site filter and show both buildings again", async () => { - await fleetLocationsPage.clearActiveFilter("site"); - - await fleetLocationsPage.validateActiveFilterNotVisible("Sites"); - await fleetLocationsPage.validateCurrentBuildingVisible(primary.buildingName); - await fleetLocationsPage.validateCurrentBuildingVisible(secondary.buildingName); - - test.expect(new URL(page.url()).searchParams.getAll("site")).toEqual([]); - }); - }); - - test("Racks site and building filters can reach no results and then clear cleanly", async ({ - page, - fleetLocationsPage, - racksPage, - }) => { - const primary = createBuildingsScenarioData(); - const secondary = createBuildingsScenarioData(); - - await setupRackAssignedToBuilding(page, fleetLocationsPage, racksPage, primary); - const { buildingId: secondaryBuildingId } = await setupRackAssignedToBuilding( - page, - fleetLocationsPage, - racksPage, - secondary, - ); - - const primarySiteId = await fleetLocationsPage.getSiteIdByName(primary.siteName); - - await test.step("Apply a site filter and confirm only the matching rack remains", async () => { - await racksPage.navigateToRacksPage(); - await racksPage.clickViewList(); - await racksPage.waitForRackListToLoad({ allowEmpty: false }); - - await racksPage.applySiteFilter([primary.siteName]); - await racksPage.waitForRackListToLoad({ allowEmpty: false }); - - await racksPage.validateActiveFilterSummary("site", primary.siteName); - await racksPage.validateRackPlacementRow(primary.rackLabel, primary.siteName, primary.buildingName); - await racksPage.validateRackNotVisible(secondary.rackLabel); - - test.expect(new URL(page.url()).searchParams.getAll("site")).toEqual([primarySiteId.toString()]); - }); - - await test.step("Add a mismatched building filter to force the no-results state", async () => { - await racksPage.applyBuildingFilter([secondary.buildingName]); - await racksPage.waitForRackListToLoad(); - - await racksPage.validateActiveFilterSummary("building", secondary.buildingName); - await racksPage.validateNoResultsEmptyState(); - - const searchParams = new URL(page.url()).searchParams; - test.expect(searchParams.getAll("site")).toEqual([primarySiteId.toString()]); - test.expect(searchParams.getAll("building")).toEqual([secondaryBuildingId.toString()]); - }); - - await test.step("Clear all filters and restore the full rack list", async () => { - await racksPage.clickClearAllFilters(); - await racksPage.waitForRackListToLoad({ allowEmpty: false }); - - await racksPage.validateActiveFilterNotVisible("Sites"); - await racksPage.validateActiveFilterNotVisible("Buildings"); - await racksPage.validateRackPlacementRow(primary.rackLabel, primary.siteName, primary.buildingName); - await racksPage.validateRackPlacementRow(secondary.rackLabel, secondary.siteName, secondary.buildingName); - - const searchParams = new URL(page.url()).searchParams; - test.expect(searchParams.getAll("site")).toEqual([]); - test.expect(searchParams.getAll("building")).toEqual([]); - }); - }); + }, + ); }); diff --git a/client/e2eTests/protoFleet/spec/fleetSavedViews.spec.ts b/client/e2eTests/protoFleet/spec/fleetSavedViews.spec.ts index 8ca0e4569a..05b3eac2e7 100644 --- a/client/e2eTests/protoFleet/spec/fleetSavedViews.spec.ts +++ b/client/e2eTests/protoFleet/spec/fleetSavedViews.spec.ts @@ -10,67 +10,67 @@ import { generateRandomText } from "../helpers/testDataHelper"; test.describe("Proto Fleet - Fleet saved views", () => { useBuildingsHooks(); - test("Buildings saved view restores the site-filtered fleet view", async ({ - page, - fleetLocationsPage, - racksPage, - }) => { - const scenario = createBuildingsScenarioData(); - const viewName = generateRandomText("buildings_view"); - - await setupRackAssignedToBuilding(page, fleetLocationsPage, racksPage, scenario); - - let siteId = 0n; - - await test.step("Open the buildings tab from the site row and save the filtered view", async () => { - siteId = await fleetLocationsPage.openBuildingsForSite(scenario.siteName); - - await fleetLocationsPage.validateCurrentBuildingRowCounts(scenario.buildingName, { - siteName: scenario.siteName, - racks: 1, - miners: 2, + test( + "Buildings saved view restores the site-filtered fleet view", + { tag: "@smoke" }, + async ({ page, fleetLocationsPage, racksPage }) => { + const scenario = createBuildingsScenarioData(); + const viewName = generateRandomText("buildings_view"); + + await setupRackAssignedToBuilding(page, fleetLocationsPage, racksPage, scenario); + + let siteId = 0n; + + await test.step("Open the buildings tab from the site row and save the filtered view", async () => { + siteId = await fleetLocationsPage.openBuildingsForSite(scenario.siteName); + + await fleetLocationsPage.validateCurrentBuildingRowCounts(scenario.buildingName, { + siteName: scenario.siteName, + racks: 1, + miners: 2, + }); + + let searchParams = new URL(page.url()).searchParams; + test.expect(searchParams.getAll("site")).toEqual([siteId.toString()]); + + await fleetLocationsPage.clickNewSavedViewButton(); + await fleetLocationsPage.validateViewModalOpened("New view"); + await fleetLocationsPage.inputViewName(viewName); + await fleetLocationsPage.saveNewView(); + await fleetLocationsPage.validateViewTabActive(viewName); }); - let searchParams = new URL(page.url()).searchParams; - test.expect(searchParams.getAll("site")).toEqual([siteId.toString()]); - - await fleetLocationsPage.clickNewSavedViewButton(); - await fleetLocationsPage.validateViewModalOpened("New view"); - await fleetLocationsPage.inputViewName(viewName); - await fleetLocationsPage.saveNewView(); - await fleetLocationsPage.validateViewTabActive(viewName); - }); - - await test.step("Clear the site filter so the saved view becomes dirty", async () => { - await fleetLocationsPage.clearActiveFilter("site"); - - const searchParams = new URL(page.url()).searchParams; - test.expect(searchParams.getAll("site")).toEqual([]); - test.expect(searchParams.get("view")).not.toBeNull(); - }); + await test.step("Clear the site filter so the saved view becomes dirty", async () => { + await fleetLocationsPage.clearActiveFilter("site"); - await test.step("Reset and then delete the saved view", async () => { - await fleetLocationsPage.clickResetViewAction(viewName); - await fleetLocationsPage.validateViewTabActive(viewName); - await fleetLocationsPage.validateCurrentBuildingRowCounts(scenario.buildingName, { - siteName: scenario.siteName, - racks: 1, - miners: 2, + const searchParams = new URL(page.url()).searchParams; + test.expect(searchParams.getAll("site")).toEqual([]); + test.expect(searchParams.get("view")).not.toBeNull(); }); - let searchParams = new URL(page.url()).searchParams; - test.expect(searchParams.getAll("site")).toEqual([siteId.toString()]); - - await fleetLocationsPage.clickDeleteViewAction(viewName); - await fleetLocationsPage.validateDeleteViewDialogOpened(viewName); - await fleetLocationsPage.confirmDeleteView(); - await fleetLocationsPage.validateViewTabNotVisible(viewName); - - searchParams = new URL(page.url()).searchParams; - test.expect(searchParams.get("view")).toBeNull(); - test.expect(searchParams.getAll("site")).toEqual([siteId.toString()]); - }); - }); + await test.step("Reset and then delete the saved view", async () => { + await fleetLocationsPage.clickResetViewAction(viewName); + await fleetLocationsPage.validateViewTabActive(viewName); + await fleetLocationsPage.validateCurrentBuildingRowCounts(scenario.buildingName, { + siteName: scenario.siteName, + racks: 1, + miners: 2, + }); + + let searchParams = new URL(page.url()).searchParams; + test.expect(searchParams.getAll("site")).toEqual([siteId.toString()]); + + await fleetLocationsPage.clickDeleteViewAction(viewName); + await fleetLocationsPage.validateDeleteViewDialogOpened(viewName); + await fleetLocationsPage.confirmDeleteView(); + await fleetLocationsPage.validateViewTabNotVisible(viewName); + + searchParams = new URL(page.url()).searchParams; + test.expect(searchParams.get("view")).toBeNull(); + test.expect(searchParams.getAll("site")).toEqual([siteId.toString()]); + }); + }, + ); test("Racks saved view restores the building filter and display mode", async ({ page, diff --git a/client/e2eTests/protoFleet/spec/generalSettings.spec.ts b/client/e2eTests/protoFleet/spec/generalSettings.spec.ts index 07b5adc132..8230c3f238 100644 --- a/client/e2eTests/protoFleet/spec/generalSettings.spec.ts +++ b/client/e2eTests/protoFleet/spec/generalSettings.spec.ts @@ -38,34 +38,33 @@ test.describe("General Settings", () => { } }); - test("Render network details from the fleet network info API", async ({ - authPage, - settingsPage, - commonSteps, - page, - }) => { - await commonSteps.loginAsAdmin(); + test( + "Render network details from the fleet network info API", + { tag: "@smoke" }, + async ({ authPage, settingsPage, commonSteps, page }) => { + await commonSteps.loginAsAdmin(); - const networkInfoResponsePromise = page.waitForResponse((response) => response.url().includes("GetNetworkInfo")); + const networkInfoResponsePromise = page.waitForResponse((response) => response.url().includes("GetNetworkInfo")); - let subnet = ""; - let gateway = ""; + let subnet = ""; + let gateway = ""; - await test.step("Navigate to network settings and capture the network info response", async () => { - await authPage.navigateToNetworkSettings(); - const response = await networkInfoResponsePromise; - const body = await response.json(); + await test.step("Navigate to network settings and capture the network info response", async () => { + await authPage.navigateToNetworkSettings(); + const response = await networkInfoResponsePromise; + const body = await response.json(); - subnet = body.networkInfo?.subnet ?? ""; - gateway = body.networkInfo?.gateway ?? ""; - }); + subnet = body.networkInfo?.subnet ?? ""; + gateway = body.networkInfo?.gateway ?? ""; + }); - await test.step("Validate network details are rendered", async () => { - test.expect(subnet).toBeTruthy(); - test.expect(gateway).toBeTruthy(); - await settingsPage.validateNetworkDetails(subnet, gateway); - }); - }); + await test.step("Validate network details are rendered", async () => { + test.expect(subnet).toBeTruthy(); + test.expect(gateway).toBeTruthy(); + await settingsPage.validateNetworkDetails(subnet, gateway); + }); + }, + ); test("Set temperature format", async ({ authPage, settingsPage, minersPage, commonSteps }) => { await commonSteps.loginAsAdmin(); @@ -105,47 +104,51 @@ test.describe("General Settings", () => { }); }); - test("Theme preference persists after refresh", async ({ authPage, settingsPage, commonSteps }) => { - await commonSteps.loginAsAdmin(); - - let originalTheme: SettingsTheme = "System"; - let targetTheme: "Light" | "Dark" = "Dark"; - - const targetThemeByCurrentTheme: Record = { - Dark: "Light", - Light: "Dark", - System: "Dark", - }; - const bodyThemeByTheme: Record<"Light" | "Dark", "light" | "dark"> = { - Light: "light", - Dark: "dark", - }; - - await test.step("Navigate to preferences settings and capture the current theme", async () => { - await authPage.navigateToPreferencesSettings(); - originalTheme = await settingsPage.getCurrentTheme(); - targetTheme = targetThemeByCurrentTheme[originalTheme] ?? "Dark"; - }); - - await test.step("Change the theme to a deterministic value", async () => { - await settingsPage.clickThemeButton(); - await settingsPage.selectTheme(targetTheme); - await settingsPage.clickDoneButton(); - await settingsPage.validateCurrentTheme(targetTheme); - await settingsPage.validateBodyTheme(bodyThemeByTheme[targetTheme]); - }); - - await test.step("Refresh and validate theme persistence", async () => { - await settingsPage.reloadPage(); - await settingsPage.validateCurrentTheme(targetTheme); - await settingsPage.validateBodyTheme(bodyThemeByTheme[targetTheme]); - }); + test( + "Theme preference persists after refresh", + { tag: "@smoke" }, + async ({ authPage, settingsPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); - await test.step("Restore the original theme", async () => { - await settingsPage.clickThemeButton(); - await settingsPage.selectTheme(originalTheme); - await settingsPage.clickDoneButton(); - await settingsPage.validateCurrentTheme(originalTheme); - }); - }); + let originalTheme: SettingsTheme = "System"; + let targetTheme: "Light" | "Dark" = "Dark"; + + const targetThemeByCurrentTheme: Record = { + Dark: "Light", + Light: "Dark", + System: "Dark", + }; + const bodyThemeByTheme: Record<"Light" | "Dark", "light" | "dark"> = { + Light: "light", + Dark: "dark", + }; + + await test.step("Navigate to preferences settings and capture the current theme", async () => { + await authPage.navigateToPreferencesSettings(); + originalTheme = await settingsPage.getCurrentTheme(); + targetTheme = targetThemeByCurrentTheme[originalTheme] ?? "Dark"; + }); + + await test.step("Change the theme to a deterministic value", async () => { + await settingsPage.clickThemeButton(); + await settingsPage.selectTheme(targetTheme); + await settingsPage.clickDoneButton(); + await settingsPage.validateCurrentTheme(targetTheme); + await settingsPage.validateBodyTheme(bodyThemeByTheme[targetTheme]); + }); + + await test.step("Refresh and validate theme persistence", async () => { + await settingsPage.reloadPage(); + await settingsPage.validateCurrentTheme(targetTheme); + await settingsPage.validateBodyTheme(bodyThemeByTheme[targetTheme]); + }); + + await test.step("Restore the original theme", async () => { + await settingsPage.clickThemeButton(); + await settingsPage.selectTheme(originalTheme); + await settingsPage.clickDoneButton(); + await settingsPage.validateCurrentTheme(originalTheme); + }); + }, + ); }); diff --git a/client/e2eTests/protoFleet/spec/groups.spec.ts b/client/e2eTests/protoFleet/spec/groups.spec.ts index 872bdd8511..653d77eb0a 100644 --- a/client/e2eTests/protoFleet/spec/groups.spec.ts +++ b/client/e2eTests/protoFleet/spec/groups.spec.ts @@ -76,7 +76,7 @@ test.describe("Groups", () => { } } - test("Create, edit, and delete groups", async ({ groupsPage }) => { + test("Create, edit, and delete groups", { tag: "@smoke" }, async ({ groupsPage }) => { const groupName = generateRandomText("automation"); const editedGroupName = generateRandomText("automation-edited"); @@ -128,7 +128,7 @@ test.describe("Groups", () => { }); }); - test("Validate groups association to miners", async ({ groupsPage }) => { + test("Validate groups association to miners", { tag: "@smoke" }, async ({ groupsPage }) => { const group1Name = generateRandomText("automation1"); const group2Name = generateRandomText("automation2"); const group3Name = generateRandomText("automation3"); @@ -392,75 +392,75 @@ test.describe("Groups", () => { }); }); - test("Group overview actions menu manages power for selected rig miners", async ({ - groupsPage, - minersPage, - page, - }) => { - const groupName = generateRandomText("automation"); - let minerCount = 0; - let selectedDeviceIdentifiers: string[] = []; - - await test.step("Create a rig-only group with two miners", async () => { - const createGroupRequestPromise = page.waitForRequest(/CreateDeviceSet/); - - await groupsPage.clickAddGroupButton(); - await groupsPage.inputGroupName(groupName); - await groupsPage.waitForModalListToLoad(); - await groupsPage.filterModalType(PROTO_RIG_MODEL); - await groupsPage.waitForModalListToLoad(); - - minerCount = 2; - await groupsPage.selectMinersByIndex([0, 1]); - await groupsPage.clickSaveInModal(); - - const createGroupRequest = await createGroupRequestPromise; - const createGroupRequestBody = createGroupRequest.postDataJSON(); - selectedDeviceIdentifiers = createGroupRequestBody.deviceSelector.deviceList.deviceIdentifiers; - - await groupsPage.validateTextInToast(`Group "${groupName}" created`); - await groupsPage.validateSavedGroupVisible(groupName); - await groupsPage.validateSavedGroupMinerCount(groupName, minerCount); - test.expect(selectedDeviceIdentifiers).toHaveLength(minerCount); - }); - - await test.step("Open the group overview", async () => { - await groupsPage.openSavedGroupOverview(groupName); - }); - - const requestPromise = page.waitForRequest(/SetPowerTarget/); - const responsePromise = page.waitForResponse(/SetPowerTarget/); - - await test.step("Use the overview actions menu to reduce power", async () => { - await groupsPage.openGroupOverviewActionsMenu(); - await groupsPage.clickGroupOverviewManagePower(); - await minersPage.clickReducePowerOption(); - await minersPage.clickManagePowerConfirm(); - }); - - await test.step("Validate manage power toasts", async () => { - await groupsPage.validateTextInToastGroup("Updating power settings"); - await groupsPage.validateTextInToastGroup("Updated power settings"); - }); - - await test.step("Validate the SetPowerTarget request targets the grouped miners", async () => { - const request = await requestPromise; - const response = await responsePromise; - const requestBody = request.postDataJSON(); - const targetedDeviceIdentifiers = requestBody.deviceSelector.includeDevices.deviceIdentifiers; - const sortedTargetedDeviceIdentifiers = [...targetedDeviceIdentifiers].sort(); - const sortedSelectedDeviceIdentifiers = [...selectedDeviceIdentifiers].sort(); + test( + "Group overview actions menu manages power for selected rig miners", + { tag: "@smoke" }, + async ({ groupsPage, minersPage, page }) => { + const groupName = generateRandomText("automation"); + let minerCount = 0; + let selectedDeviceIdentifiers: string[] = []; - test.expect(request.method()).toBe("POST"); - test.expect(requestBody).toHaveProperty("performanceMode"); - test.expect(requestBody.performanceMode).toBe("PERFORMANCE_MODE_EFFICIENCY"); - test.expect(requestBody).toHaveProperty("deviceSelector"); - test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); - test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); - test.expect(sortedTargetedDeviceIdentifiers).toEqual(sortedSelectedDeviceIdentifiers); - test.expect(response.status()).toBe(200); - }); - }); + await test.step("Create a rig-only group with two miners", async () => { + const createGroupRequestPromise = page.waitForRequest(/CreateDeviceSet/); + + await groupsPage.clickAddGroupButton(); + await groupsPage.inputGroupName(groupName); + await groupsPage.waitForModalListToLoad(); + await groupsPage.filterModalType(PROTO_RIG_MODEL); + await groupsPage.waitForModalListToLoad(); + + minerCount = 2; + await groupsPage.selectMinersByIndex([0, 1]); + await groupsPage.clickSaveInModal(); + + const createGroupRequest = await createGroupRequestPromise; + const createGroupRequestBody = createGroupRequest.postDataJSON(); + selectedDeviceIdentifiers = createGroupRequestBody.deviceSelector.deviceList.deviceIdentifiers; + + await groupsPage.validateTextInToast(`Group "${groupName}" created`); + await groupsPage.validateSavedGroupVisible(groupName); + await groupsPage.validateSavedGroupMinerCount(groupName, minerCount); + test.expect(selectedDeviceIdentifiers).toHaveLength(minerCount); + }); + + await test.step("Open the group overview", async () => { + await groupsPage.openSavedGroupOverview(groupName); + }); + + const requestPromise = page.waitForRequest(/SetPowerTarget/); + const responsePromise = page.waitForResponse(/SetPowerTarget/); + + await test.step("Use the overview actions menu to reduce power", async () => { + await groupsPage.openGroupOverviewActionsMenu(); + await groupsPage.clickGroupOverviewManagePower(); + await minersPage.clickReducePowerOption(); + await minersPage.clickManagePowerConfirm(); + }); + + await test.step("Validate manage power toasts", async () => { + await groupsPage.validateTextInToastGroup("Updating power settings"); + await groupsPage.validateTextInToastGroup("Updated power settings"); + }); + + await test.step("Validate the SetPowerTarget request targets the grouped miners", async () => { + const request = await requestPromise; + const response = await responsePromise; + const requestBody = request.postDataJSON(); + const targetedDeviceIdentifiers = requestBody.deviceSelector.includeDevices.deviceIdentifiers; + const sortedTargetedDeviceIdentifiers = [...targetedDeviceIdentifiers].sort(); + const sortedSelectedDeviceIdentifiers = [...selectedDeviceIdentifiers].sort(); + + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("performanceMode"); + test.expect(requestBody.performanceMode).toBe("PERFORMANCE_MODE_EFFICIENCY"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(sortedTargetedDeviceIdentifiers).toEqual(sortedSelectedDeviceIdentifiers); + test.expect(response.status()).toBe(200); + }); + }, + ); if (testConfig.target !== "real") { test("Group overview actions menu assigns pools to grouped rig miners", async ({ diff --git a/client/e2eTests/protoFleet/spec/minerIssues.spec.ts b/client/e2eTests/protoFleet/spec/minerIssues.spec.ts index 777ad70225..1a1f1ea48e 100644 --- a/client/e2eTests/protoFleet/spec/minerIssues.spec.ts +++ b/client/e2eTests/protoFleet/spec/minerIssues.spec.ts @@ -6,7 +6,7 @@ test.describe("Miner Issues Tests", () => { await page.goto("/"); }); - test("mock ErrorQueryService with custom errors", async ({ page, minersPage, commonSteps }) => { + test("mock ErrorQueryService with custom errors", { tag: "@smoke" }, async ({ page, minersPage, commonSteps }) => { const errorControlBoard = "COMPONENT_TYPE_CONTROL_BOARD"; const errorHashBoard = "COMPONENT_TYPE_HASH_BOARD"; const errorPsu = "COMPONENT_TYPE_PSU"; diff --git a/client/e2eTests/protoFleet/spec/minersActions.spec.ts b/client/e2eTests/protoFleet/spec/minersActions.spec.ts index f1b38f1316..c576ba7ebd 100644 --- a/client/e2eTests/protoFleet/spec/minersActions.spec.ts +++ b/client/e2eTests/protoFleet/spec/minersActions.spec.ts @@ -5,7 +5,7 @@ test.describe("Miners", () => { await page.goto("/"); }); - test("REBOOT a single miner", async ({ minersPage, page, commonSteps }) => { + test("REBOOT a single miner", { tag: "@smoke" }, async ({ minersPage, page, commonSteps }) => { await commonSteps.loginAsAdmin(); await commonSteps.goToMinersPage(); @@ -150,7 +150,7 @@ test.describe("Miners", () => { }); }); - test("MANAGE POWER for multiple miners", async ({ minersPage, page, commonSteps }) => { + test("MANAGE POWER for multiple miners", { tag: "@smoke" }, async ({ minersPage, page, commonSteps }) => { await commonSteps.loginAsAdmin(); await commonSteps.goToMinersPage(); diff --git a/client/e2eTests/protoFleet/spec/minersAddRemove.spec.ts b/client/e2eTests/protoFleet/spec/minersAddRemove.spec.ts index 6844491987..ec5524fc2a 100644 --- a/client/e2eTests/protoFleet/spec/minersAddRemove.spec.ts +++ b/client/e2eTests/protoFleet/spec/minersAddRemove.spec.ts @@ -77,7 +77,7 @@ test.describe("Miners UNPAIR - ADD actions", () => { } }); - test("UNPAIR - ADD a single miner", async ({ minersPage, commonSteps, addMinersPage }) => { + test("UNPAIR - ADD a single miner", { tag: "@smoke" }, async ({ minersPage, commonSteps, addMinersPage }) => { await commonSteps.loginAsAdmin(); await commonSteps.goToMinersPage(); diff --git a/client/e2eTests/protoFleet/spec/minersFiltersViews.spec.ts b/client/e2eTests/protoFleet/spec/minersFiltersViews.spec.ts index 38ccad4f37..d781d9bf57 100644 --- a/client/e2eTests/protoFleet/spec/minersFiltersViews.spec.ts +++ b/client/e2eTests/protoFleet/spec/minersFiltersViews.spec.ts @@ -7,132 +7,136 @@ test.describe("Proto Fleet - Miners filters and saved views", () => { await page.goto("/"); }); - test("Numeric and subnet filters persist through reload and clear cleanly", async ({ - minersPage, - commonSteps, - page, - }) => { - let initialMinerCount = 0; - let filteredMinerIp = ""; - let targetSubnet = ""; - let powerMin: number | undefined; - let powerMax: number | undefined; - - await commonSteps.loginAsAdmin(); - await commonSteps.goToMinersPage(); - - await test.step("Capture a target miner and its filter values", async () => { - initialMinerCount = await minersPage.getMinersCount(); - filteredMinerIp = await getFirstVisibleIpv4MinerIp(minersPage); - targetSubnet = toSubnet24(filteredMinerIp); - - powerMin = 2; - powerMax = undefined; - - test.expect(initialMinerCount).toBeGreaterThan(0); - }); - - await test.step("Apply subnet and power filters", async () => { - await minersPage.applySubnetFilter([targetSubnet]); - await minersPage.waitForMinersListToLoad(); - await minersPage.applyPowerFilter(powerMin, powerMax); - await minersPage.waitForMinersListToLoad(); - }); - - await test.step("Validate filtered results, chips, and URL", async () => { - filteredMinerIp = await minersPage.getMinerIpAddressByIndex(0); - await minersPage.validateActiveFilterSummary("subnet", targetSubnet); - await minersPage.validateActiveFilterSummary("power", formatPowerFilterSummary(powerMin, powerMax)); - await minersPage.validateMinerInList(filteredMinerIp); - test.expect(await minersPage.getMinersCount()).toBeGreaterThan(0); - - const searchParams = new URL(page.url()).searchParams; - test.expect(searchParams.getAll("subnet")).toEqual([targetSubnet]); - test.expect(searchParams.get("power_min")).toBe(String(powerMin)); - test.expect(searchParams.get("power_max")).toBeNull(); - }); - - await test.step("Reload and validate the filters persist", async () => { - await minersPage.reloadPage(); - await minersPage.waitForMinersTitle(); - await minersPage.waitForMinersListToLoad(); - - await minersPage.validateActiveFilterSummary("subnet", targetSubnet); - await minersPage.validateActiveFilterSummary("power", formatPowerFilterSummary(powerMin, powerMax)); - await minersPage.validateMinerInList(filteredMinerIp); - test.expect(await minersPage.getMinersCount()).toBeGreaterThan(0); - }); - - await test.step("Apply a strict power filter and validate the empty state", async () => { - await minersPage.applyPowerFilter(50, 50); - await minersPage.validateNoResultsEmptyState(); - }); - - await test.step("Clear the filters and validate the full list returns", async () => { - await minersPage.clickClearAllFilters(); - await minersPage.waitForMinersListToLoad(); - - test.expect(await minersPage.getMinersCount()).toBe(initialMinerCount); - - const searchParams = new URL(page.url()).searchParams; - test.expect(searchParams.getAll("subnet")).toEqual([]); - test.expect(searchParams.get("power_min")).toBeNull(); - test.expect(searchParams.get("power_max")).toBeNull(); - }); - }); - - test("Saved view can be created and reset back to its saved filters", async ({ minersPage, commonSteps, page }) => { - const viewName = generateRandomText("miners_view"); - let firstMinerIp = ""; - let firstMinerSubnet = ""; - const dirtyPowerMin = 2; - const dirtyPowerMax = undefined; - - await commonSteps.loginAsAdmin(); - await commonSteps.goToMinersPage(); - - await test.step("Capture a miner and save a view for its subnet", async () => { - firstMinerIp = await getFirstVisibleIpv4MinerIp(minersPage); - firstMinerSubnet = toSubnet24(firstMinerIp); - - await minersPage.applySubnetFilter([firstMinerSubnet]); - await minersPage.waitForMinersListToLoad(); - firstMinerIp = await minersPage.getMinerIpAddressByIndex(0); - await minersPage.clickNewSavedViewButton(); - await minersPage.validateViewModalOpened("New view"); - await minersPage.inputViewName(viewName); - await minersPage.saveNewView(); - }); - - await test.step("Validate the new view is active", async () => { - await minersPage.validateViewTabVisible(viewName); - await minersPage.validateViewTabActive(viewName); - await minersPage.validateActiveFilterSummary("subnet", firstMinerSubnet); - await minersPage.validateMinerInList(firstMinerIp); - }); - - await test.step("Change the live filters so the view becomes dirty", async () => { - await minersPage.applyPowerFilter(dirtyPowerMin, dirtyPowerMax); - await minersPage.waitForMinersListToLoad(); - - await minersPage.validateActiveFilterSummary("subnet", firstMinerSubnet); - await minersPage.validateActiveFilterSummary("power", formatPowerFilterSummary(dirtyPowerMin, dirtyPowerMax)); - test.expect(await minersPage.getMinersCount()).toBeGreaterThan(0); - }); - - await test.step("Reset the view back to the saved filters", async () => { - await minersPage.clickResetViewAction(viewName); - await minersPage.waitForMinersListToLoad(); - - await minersPage.validateViewTabActive(viewName); - await minersPage.validateActiveFilterSummary("subnet", firstMinerSubnet); - await minersPage.validateActiveFilterNotVisible("Power"); - await minersPage.validateMinerInList(firstMinerIp); - test.expect(new URL(page.url()).searchParams.getAll("subnet")).toEqual([firstMinerSubnet]); - test.expect(new URL(page.url()).searchParams.get("power_min")).toBeNull(); - test.expect(new URL(page.url()).searchParams.get("power_max")).toBeNull(); - }); - }); + test( + "Numeric and subnet filters persist through reload and clear cleanly", + { tag: "@smoke" }, + async ({ minersPage, commonSteps, page }) => { + let initialMinerCount = 0; + let filteredMinerIp = ""; + let targetSubnet = ""; + let powerMin: number | undefined; + let powerMax: number | undefined; + + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Capture a target miner and its filter values", async () => { + initialMinerCount = await minersPage.getMinersCount(); + filteredMinerIp = await getFirstVisibleIpv4MinerIp(minersPage); + targetSubnet = toSubnet24(filteredMinerIp); + + powerMin = 2; + powerMax = undefined; + + test.expect(initialMinerCount).toBeGreaterThan(0); + }); + + await test.step("Apply subnet and power filters", async () => { + await minersPage.applySubnetFilter([targetSubnet]); + await minersPage.waitForMinersListToLoad(); + await minersPage.applyPowerFilter(powerMin, powerMax); + await minersPage.waitForMinersListToLoad(); + }); + + await test.step("Validate filtered results, chips, and URL", async () => { + filteredMinerIp = await minersPage.getMinerIpAddressByIndex(0); + await minersPage.validateActiveFilterSummary("subnet", targetSubnet); + await minersPage.validateActiveFilterSummary("power", formatPowerFilterSummary(powerMin, powerMax)); + await minersPage.validateMinerInList(filteredMinerIp); + test.expect(await minersPage.getMinersCount()).toBeGreaterThan(0); + + const searchParams = new URL(page.url()).searchParams; + test.expect(searchParams.getAll("subnet")).toEqual([targetSubnet]); + test.expect(searchParams.get("power_min")).toBe(String(powerMin)); + test.expect(searchParams.get("power_max")).toBeNull(); + }); + + await test.step("Reload and validate the filters persist", async () => { + await minersPage.reloadPage(); + await minersPage.waitForMinersTitle(); + await minersPage.waitForMinersListToLoad(); + + await minersPage.validateActiveFilterSummary("subnet", targetSubnet); + await minersPage.validateActiveFilterSummary("power", formatPowerFilterSummary(powerMin, powerMax)); + await minersPage.validateMinerInList(filteredMinerIp); + test.expect(await minersPage.getMinersCount()).toBeGreaterThan(0); + }); + + await test.step("Apply a strict power filter and validate the empty state", async () => { + await minersPage.applyPowerFilter(50, 50); + await minersPage.validateNoResultsEmptyState(); + }); + + await test.step("Clear the filters and validate the full list returns", async () => { + await minersPage.clickClearAllFilters(); + await minersPage.waitForMinersListToLoad(); + + test.expect(await minersPage.getMinersCount()).toBe(initialMinerCount); + + const searchParams = new URL(page.url()).searchParams; + test.expect(searchParams.getAll("subnet")).toEqual([]); + test.expect(searchParams.get("power_min")).toBeNull(); + test.expect(searchParams.get("power_max")).toBeNull(); + }); + }, + ); + + test( + "Saved view can be created and reset back to its saved filters", + { tag: "@smoke" }, + async ({ minersPage, commonSteps, page }) => { + const viewName = generateRandomText("miners_view"); + let firstMinerIp = ""; + let firstMinerSubnet = ""; + const dirtyPowerMin = 2; + const dirtyPowerMax = undefined; + + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Capture a miner and save a view for its subnet", async () => { + firstMinerIp = await getFirstVisibleIpv4MinerIp(minersPage); + firstMinerSubnet = toSubnet24(firstMinerIp); + + await minersPage.applySubnetFilter([firstMinerSubnet]); + await minersPage.waitForMinersListToLoad(); + firstMinerIp = await minersPage.getMinerIpAddressByIndex(0); + await minersPage.clickNewSavedViewButton(); + await minersPage.validateViewModalOpened("New view"); + await minersPage.inputViewName(viewName); + await minersPage.saveNewView(); + }); + + await test.step("Validate the new view is active", async () => { + await minersPage.validateViewTabVisible(viewName); + await minersPage.validateViewTabActive(viewName); + await minersPage.validateActiveFilterSummary("subnet", firstMinerSubnet); + await minersPage.validateMinerInList(firstMinerIp); + }); + + await test.step("Change the live filters so the view becomes dirty", async () => { + await minersPage.applyPowerFilter(dirtyPowerMin, dirtyPowerMax); + await minersPage.waitForMinersListToLoad(); + + await minersPage.validateActiveFilterSummary("subnet", firstMinerSubnet); + await minersPage.validateActiveFilterSummary("power", formatPowerFilterSummary(dirtyPowerMin, dirtyPowerMax)); + test.expect(await minersPage.getMinersCount()).toBeGreaterThan(0); + }); + + await test.step("Reset the view back to the saved filters", async () => { + await minersPage.clickResetViewAction(viewName); + await minersPage.waitForMinersListToLoad(); + + await minersPage.validateViewTabActive(viewName); + await minersPage.validateActiveFilterSummary("subnet", firstMinerSubnet); + await minersPage.validateActiveFilterNotVisible("Power"); + await minersPage.validateMinerInList(firstMinerIp); + test.expect(new URL(page.url()).searchParams.getAll("subnet")).toEqual([firstMinerSubnet]); + test.expect(new URL(page.url()).searchParams.get("power_min")).toBeNull(); + test.expect(new URL(page.url()).searchParams.get("power_max")).toBeNull(); + }); + }, + ); test("Saved view can be updated after the filters change", async ({ minersPage, commonSteps, page }) => { const viewName = generateRandomText("miners_view"); diff --git a/client/e2eTests/protoFleet/spec/minersRename.spec.ts b/client/e2eTests/protoFleet/spec/minersRename.spec.ts index 112a5cf10e..7601acad85 100644 --- a/client/e2eTests/protoFleet/spec/minersRename.spec.ts +++ b/client/e2eTests/protoFleet/spec/minersRename.spec.ts @@ -77,7 +77,7 @@ test.describe("Miners Rename", () => { } }); - test("Validate bulk rename functionality", async ({ minersPage, commonSteps }) => { + test("Validate bulk rename functionality", { tag: "@smoke" }, async ({ minersPage, commonSteps }) => { await commonSteps.loginAsAdmin(); await commonSteps.goToMinersPage(); await minersPage.setBulkRenamePropertyOrder(BULK_RENAME_PROPERTIES); @@ -315,7 +315,7 @@ test.describe("Miners Rename", () => { }); }); - test("RENAME a single miner", async ({ minersPage, page, commonSteps }) => { + test("RENAME a single miner", { tag: "@smoke" }, async ({ minersPage, page, commonSteps }) => { await commonSteps.loginAsAdmin(); await commonSteps.goToMinersPage(); diff --git a/client/e2eTests/protoFleet/spec/minersSettingsActions.spec.ts b/client/e2eTests/protoFleet/spec/minersSettingsActions.spec.ts index 72b8538230..1dee025a89 100644 --- a/client/e2eTests/protoFleet/spec/minersSettingsActions.spec.ts +++ b/client/e2eTests/protoFleet/spec/minersSettingsActions.spec.ts @@ -65,38 +65,38 @@ test.describe("Miner Settings Actions", () => { }); if (testConfig.target !== "real") { - test("Update worker name from a miner action menu and restore the original value", async ({ - minersPage, - commonSteps, - loginModal, - }) => { - let minerIp: string; - let originalWorkerName: string; - const updatedWorkerName = generateRandomText("worker-e2e"); - - await test.step("Find a Proto rig with an existing worker name", async () => { - await commonSteps.loginAsAdmin(); - await commonSteps.goToMinersPage(); - await minersPage.filterRigMiners(); - const [selectedWorkerNamedMiner] = await minersPage.getAuthenticatedMinersWithNonEmptyWorkerNames(1); - minerIp = selectedWorkerNamedMiner.ipAddress; - originalWorkerName = selectedWorkerNamedMiner.workerName; - }); - - await test.step("Update the worker name through the single-miner action flow", async () => { - workerNameRestoreTargets = [{ ipAddress: minerIp, workerName: originalWorkerName }]; + test( + "Update worker name from a miner action menu and restore the original value", + { tag: "@smoke" }, + async ({ minersPage, commonSteps, loginModal }) => { + let minerIp: string; + let originalWorkerName: string; + const updatedWorkerName = generateRandomText("worker-e2e"); + + await test.step("Find a Proto rig with an existing worker name", async () => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + await minersPage.filterRigMiners(); + const [selectedWorkerNamedMiner] = await minersPage.getAuthenticatedMinersWithNonEmptyWorkerNames(1); + minerIp = selectedWorkerNamedMiner.ipAddress; + originalWorkerName = selectedWorkerNamedMiner.workerName; + }); + + await test.step("Update the worker name through the single-miner action flow", async () => { + workerNameRestoreTargets = [{ ipAddress: minerIp, workerName: originalWorkerName }]; + + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickUpdateWorkerNameButton(); + await loginModal.loginAsAdminForWorkerNames(); + await minersPage.validateUpdateWorkerNameModalOpened(); + await minersPage.fillUpdateWorkerNameInput(updatedWorkerName); + await minersPage.clickSaveInModal(); - await minersPage.clickMinerThreeDotsButton(minerIp); - await minersPage.clickUpdateWorkerNameButton(); - await loginModal.loginAsAdminForWorkerNames(); - await minersPage.validateUpdateWorkerNameModalOpened(); - await minersPage.fillUpdateWorkerNameInput(updatedWorkerName); - await minersPage.clickSaveInModal(); - - await minersPage.validateTextInToastGroup("Worker name updated"); - await minersPage.validateMinerWorkerName(minerIp, updatedWorkerName); - }); - }); + await minersPage.validateTextInToastGroup("Worker name updated"); + await minersPage.validateMinerWorkerName(minerIp, updatedWorkerName); + }); + }, + ); test("Bulk update worker names action updates the selected miners", async ({ minersPage, @@ -152,36 +152,35 @@ test.describe("Miner Settings Actions", () => { }); } - test("Manage security opens from the miner action menu and validates password input", async ({ - minersPage, - commonSteps, - loginModal, - page, - }) => { - await test.step("Open Manage security for a Proto rig", async () => { - await commonSteps.loginAsAdmin(); - await commonSteps.goToMinersPage(); - await minersPage.filterRigMiners(); + test( + "Manage security opens from the miner action menu and validates password input", + { tag: "@smoke" }, + async ({ minersPage, commonSteps, loginModal, page }) => { + await test.step("Open Manage security for a Proto rig", async () => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + await minersPage.filterRigMiners(); - const minerIp = await minersPage.getAuthenticatedMinerIpAddressByIndex(0); - await minersPage.clickMinerThreeDotsButton(minerIp); - await minersPage.clickManageSecurityButton(); - await loginModal.loginAsAdminForSecurity(); - await minersPage.validateManageSecurityModalOpened(); - }); + const minerIp = await minersPage.getAuthenticatedMinerIpAddressByIndex(0); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickManageSecurityButton(); + await loginModal.loginAsAdminForSecurity(); + await minersPage.validateManageSecurityModalOpened(); + }); - await test.step("Open the password modal and validate the password mismatch state", async () => { - await minersPage.clickManageSecurityUpdateButton(); - await minersPage.validateTitleInModal("Update the admin login for your miners"); - await minersPage.inputCurrentMinerPassword("root"); - await minersPage.inputNewMinerPassword("ProtoRigPass123!"); - await minersPage.inputConfirmMinerPassword("ProtoRigPass1234!"); - await minersPage.clickIn("Continue", "modal"); - await minersPage.validateTextInModal("Passwords don't match"); - - await page.getByTestId("modal").getByTestId("header-icon-button").click(); - await minersPage.closeManageSecurityModal(); - await minersPage.validateMinersPageOpened(); - }); - }); + await test.step("Open the password modal and validate the password mismatch state", async () => { + await minersPage.clickManageSecurityUpdateButton(); + await minersPage.validateTitleInModal("Update the admin login for your miners"); + await minersPage.inputCurrentMinerPassword("root"); + await minersPage.inputNewMinerPassword("ProtoRigPass123!"); + await minersPage.inputConfirmMinerPassword("ProtoRigPass1234!"); + await minersPage.clickIn("Continue", "modal"); + await minersPage.validateTextInModal("Passwords don't match"); + + await page.getByTestId("modal").getByTestId("header-icon-button").click(); + await minersPage.closeManageSecurityModal(); + await minersPage.validateMinersPageOpened(); + }); + }, + ); }); diff --git a/client/e2eTests/protoFleet/spec/onboardingVisual.spec.ts-snapshots/visual/miner-bulk-more-menu-mobile.png b/client/e2eTests/protoFleet/spec/onboardingVisual.spec.ts-snapshots/visual/miner-bulk-more-menu-mobile.png index 5811c4aaa1..17195fccba 100644 Binary files a/client/e2eTests/protoFleet/spec/onboardingVisual.spec.ts-snapshots/visual/miner-bulk-more-menu-mobile.png and b/client/e2eTests/protoFleet/spec/onboardingVisual.spec.ts-snapshots/visual/miner-bulk-more-menu-mobile.png differ diff --git a/client/e2eTests/protoFleet/spec/onboardingVisual.spec.ts-snapshots/visual/single-miner-actions-menu-mobile.png b/client/e2eTests/protoFleet/spec/onboardingVisual.spec.ts-snapshots/visual/single-miner-actions-menu-mobile.png index 4e9f102fec..339acde509 100644 Binary files a/client/e2eTests/protoFleet/spec/onboardingVisual.spec.ts-snapshots/visual/single-miner-actions-menu-mobile.png and b/client/e2eTests/protoFleet/spec/onboardingVisual.spec.ts-snapshots/visual/single-miner-actions-menu-mobile.png differ diff --git a/client/e2eTests/protoFleet/spec/racksCreation.spec.ts b/client/e2eTests/protoFleet/spec/racksCreation.spec.ts index 4646cefb40..9b509886e6 100644 --- a/client/e2eTests/protoFleet/spec/racksCreation.spec.ts +++ b/client/e2eTests/protoFleet/spec/racksCreation.spec.ts @@ -14,7 +14,7 @@ import { type RackSelectorMiner } from "../pages/racks"; test.describe("Racks - creation", () => { useRacksHooks(); - test("Create rack with miners assigned by name", async ({ racksPage }) => { + test("Create rack with miners assigned by name", { tag: "@smoke" }, async ({ racksPage }) => { let rackLabel = ""; let orderIndexValue = ""; let selectedMiners: RackSelectorMiner[] = []; @@ -69,7 +69,7 @@ test.describe("Racks - creation", () => { }); }); - test("Rack numbering updates when order index changes", async ({ racksPage }) => { + test("Rack numbering updates when order index changes", { tag: "@smoke" }, async ({ racksPage }) => { const rackLabel = createRackLabel(); let selectedMiners: RackSelectorMiner[] = []; diff --git a/client/e2eTests/protoFleet/spec/racksManagement.spec.ts b/client/e2eTests/protoFleet/spec/racksManagement.spec.ts index d105f2cb48..28cbe7894a 100644 --- a/client/e2eTests/protoFleet/spec/racksManagement.spec.ts +++ b/client/e2eTests/protoFleet/spec/racksManagement.spec.ts @@ -17,98 +17,102 @@ import { type RackSelectorMiner } from "../pages/racks"; test.describe("Racks - management", () => { useRacksHooks(); - test("Multiple racks support zone filtering and miner sorting", async ({ homePage, racksPage }) => { - const zoneA = createZoneName("A"); - const zoneB = createZoneName("B"); - const createdRackLabels: string[] = []; - - await test.step("Create rack A-01 with three miners", async () => { - await racksPage.clickAddRackButton(); - await racksPage.inputZone(zoneA); - await racksPage.inputRackLabel("A-01"); - await racksPage.enableCustomRackLayout(); - await racksPage.inputColumns(RACK_COLUMNS); - await racksPage.inputRows(RACK_ROWS); - await racksPage.clickCreateRackFromSettings(); - await racksPage.validateRackToast("A-01"); - await racksPage.clearToasts(); - await addSelectableMinersToSlots(racksPage, 3, [1, 2, 3]); - await racksPage.clickSaveMinerPositions(); - await racksPage.validateMinerPositionsToast("A-01"); - await racksPage.clearToasts(); - await racksPage.clickViewGrid(); - await racksPage.validateRackCardVisible("A-01", zoneA); - createdRackLabels.push("A-01"); - }); - - await test.step("Create rack A-02 with two miners", async () => { - await racksPage.clickAddRackButton(); - await racksPage.inputZone(zoneA); - await racksPage.inputRackLabel("A-02"); - await racksPage.clickCreateRackFromSettings(); - await racksPage.validateRackToast("A-02"); - await racksPage.clearToasts(); - await addSelectableMinersToSlots(racksPage, 2, [1, 2]); - await racksPage.clickSaveMinerPositions(); - await racksPage.validateMinerPositionsToast("A-02"); - await racksPage.clearToasts(); - await racksPage.clickViewGrid(); - await racksPage.validateRackCardVisible("A-02", zoneA); - createdRackLabels.push("A-02"); - }); - - await test.step("Create rack B-01 with one miner", async () => { - await racksPage.clickAddRackButton(); - await racksPage.inputZone(zoneB); - await racksPage.inputRackLabel("B-01"); - await racksPage.clickCreateRackFromSettings(); - await racksPage.validateRackToast("B-01"); - await racksPage.clearToasts(); - await addSelectableMinersToSlots(racksPage, 1, [1]); - await racksPage.clickSaveMinerPositions(); - await racksPage.validateMinerPositionsToast("B-01"); - await racksPage.clearToasts(); - await racksPage.clickViewGrid(); - await racksPage.validateRackCardVisible("B-01", zoneB); - createdRackLabels.push("B-01"); - }); - - await test.step("Filter racks by zone in grid view", async () => { - await racksPage.applyZoneFilter([zoneA]); - await expectGridRackLabels(racksPage, ["A-01", "A-02"]); - - await racksPage.applyZoneFilter([zoneB]); - await expectGridRackLabels(racksPage, ["B-01"]); - - await racksPage.toggleAllZoneFilters(); - await expectGridRackLabels(racksPage, createdRackLabels); - - await racksPage.toggleAllZoneFilters(); - }); - - await test.step("Filter racks by zone in list view", async () => { - await racksPage.clickViewList(); - - await racksPage.applyZoneFilter([zoneA]); - await expectListRackLabels(racksPage, ["A-01", "A-02"]); - - await racksPage.applyZoneFilter([zoneB]); - await expectListRackLabels(racksPage, ["B-01"]); - - await racksPage.toggleAllZoneFilters(); - await expectListRackLabels(racksPage, createdRackLabels); - - await racksPage.toggleAllZoneFilters(); - await racksPage.clickViewGrid(); - }); - - await test.step("Validate default grid order and miners sort order", async () => { - await homePage.dismissCompleteSetupIfVisible(); - await expectGridRackLabels(racksPage, ["A-01", "A-02", "B-01"]); - await racksPage.selectGridSort("Miners"); - await expectGridRackLabels(racksPage, ["B-01", "A-02", "A-01"]); - }); - }); + test( + "Multiple racks support zone filtering and miner sorting", + { tag: "@smoke" }, + async ({ homePage, racksPage }) => { + const zoneA = createZoneName("A"); + const zoneB = createZoneName("B"); + const createdRackLabels: string[] = []; + + await test.step("Create rack A-01 with three miners", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(zoneA); + await racksPage.inputRackLabel("A-01"); + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(RACK_COLUMNS); + await racksPage.inputRows(RACK_ROWS); + await racksPage.clickCreateRackFromSettings(); + await racksPage.validateRackToast("A-01"); + await racksPage.clearToasts(); + await addSelectableMinersToSlots(racksPage, 3, [1, 2, 3]); + await racksPage.clickSaveMinerPositions(); + await racksPage.validateMinerPositionsToast("A-01"); + await racksPage.clearToasts(); + await racksPage.clickViewGrid(); + await racksPage.validateRackCardVisible("A-01", zoneA); + createdRackLabels.push("A-01"); + }); + + await test.step("Create rack A-02 with two miners", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(zoneA); + await racksPage.inputRackLabel("A-02"); + await racksPage.clickCreateRackFromSettings(); + await racksPage.validateRackToast("A-02"); + await racksPage.clearToasts(); + await addSelectableMinersToSlots(racksPage, 2, [1, 2]); + await racksPage.clickSaveMinerPositions(); + await racksPage.validateMinerPositionsToast("A-02"); + await racksPage.clearToasts(); + await racksPage.clickViewGrid(); + await racksPage.validateRackCardVisible("A-02", zoneA); + createdRackLabels.push("A-02"); + }); + + await test.step("Create rack B-01 with one miner", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(zoneB); + await racksPage.inputRackLabel("B-01"); + await racksPage.clickCreateRackFromSettings(); + await racksPage.validateRackToast("B-01"); + await racksPage.clearToasts(); + await addSelectableMinersToSlots(racksPage, 1, [1]); + await racksPage.clickSaveMinerPositions(); + await racksPage.validateMinerPositionsToast("B-01"); + await racksPage.clearToasts(); + await racksPage.clickViewGrid(); + await racksPage.validateRackCardVisible("B-01", zoneB); + createdRackLabels.push("B-01"); + }); + + await test.step("Filter racks by zone in grid view", async () => { + await racksPage.applyZoneFilter([zoneA]); + await expectGridRackLabels(racksPage, ["A-01", "A-02"]); + + await racksPage.applyZoneFilter([zoneB]); + await expectGridRackLabels(racksPage, ["B-01"]); + + await racksPage.toggleAllZoneFilters(); + await expectGridRackLabels(racksPage, createdRackLabels); + + await racksPage.toggleAllZoneFilters(); + }); + + await test.step("Filter racks by zone in list view", async () => { + await racksPage.clickViewList(); + + await racksPage.applyZoneFilter([zoneA]); + await expectListRackLabels(racksPage, ["A-01", "A-02"]); + + await racksPage.applyZoneFilter([zoneB]); + await expectListRackLabels(racksPage, ["B-01"]); + + await racksPage.toggleAllZoneFilters(); + await expectListRackLabels(racksPage, createdRackLabels); + + await racksPage.toggleAllZoneFilters(); + await racksPage.clickViewGrid(); + }); + + await test.step("Validate default grid order and miners sort order", async () => { + await homePage.dismissCompleteSetupIfVisible(); + await expectGridRackLabels(racksPage, ["A-01", "A-02", "B-01"]); + await racksPage.selectGridSort("Miners"); + await expectGridRackLabels(racksPage, ["B-01", "A-02", "A-01"]); + }); + }, + ); test("Rack settings validation blocks invalid input and miner overflow until corrected", async ({ racksPage }) => { const validationZone = createZoneName("A"); diff --git a/client/e2eTests/protoFleet/spec/racksManualAssignment.spec.ts b/client/e2eTests/protoFleet/spec/racksManualAssignment.spec.ts index c54ff9db2d..6c85cccdae 100644 --- a/client/e2eTests/protoFleet/spec/racksManualAssignment.spec.ts +++ b/client/e2eTests/protoFleet/spec/racksManualAssignment.spec.ts @@ -13,106 +13,110 @@ import { type RackSelectorMiner } from "../pages/racks"; test.describe("Racks - manual assignment", () => { useRacksHooks(); - test("Manual rack assignment supports search, selection replacement, and saved slot state", async ({ racksPage }) => { - let rackLabel = ""; - let selectedMiners: RackSelectorMiner[] = []; - let selectableMinerIndexes: number[] = []; - - await test.step("Create a new 3x3 rack", async () => { - await racksPage.clickAddRackButton(); - await racksPage.inputZone(AUTOMATION_ZONE); - - rackLabel = createRackLabel(); - await racksPage.inputRackLabel(rackLabel); - - await racksPage.enableCustomRackLayout(); - await racksPage.inputColumns(LARGE_RACK_COLUMNS); - await racksPage.inputRows(LARGE_RACK_ROWS); - await racksPage.clickCreateRackFromSettings(); - }); - - await test.step("Manage miners and add the first miner to the rack list", async () => { - await racksPage.clickManageMiners(); - await racksPage.waitForMinerSelectorListToLoad(); - - selectableMinerIndexes = await racksPage.getSelectableMinerIndexes(2); - selectedMiners = await racksPage.getMinersFromSelector(selectableMinerIndexes); - test.expect(selectedMiners).toHaveLength(2); - await racksPage.selectMinersInSelectorByIndex([selectableMinerIndexes[0]]); - await racksPage.clickSaveInMinerSelector(); - }); - - await test.step("Search and assign the second miner to slot 04", async () => { - await racksPage.clickRackSlot(4); - await racksPage.clickRackSlotMenuItem("Search miners"); - await racksPage.assignSearchMinerByIpAddress(selectedMiners[1].ipAddress); - - await racksPage.validateMinerRowHasGreenCheck(selectedMiners[1].ipAddress); - await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 4); - await racksPage.validateRackSlotsHighlighted([4]); - }); - - await test.step("Open the assigned slot while the first miner is selected", async () => { - await racksPage.selectRackMiner(selectedMiners[0].ipAddress); - await racksPage.clickRackSlot(4); - - await racksPage.validateMinerRowHasGreenCheck(selectedMiners[1].ipAddress); - await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 4); - await racksPage.validateMinerRowUnassigned(selectedMiners[0].ipAddress); - }); - - await test.step("Replace slot 04 assignment from the list", async () => { - await racksPage.clickRackSlotMenuItem("Select from list"); - await racksPage.selectRackMiner(selectedMiners[0].ipAddress); - - await racksPage.validateMinerRowHasGreenCheck(selectedMiners[0].ipAddress); - await racksPage.validateMinerRowPosition(selectedMiners[0].ipAddress, 4); - await racksPage.validateMinerRowUnassigned(selectedMiners[1].ipAddress); - await racksPage.validateRackSlotsHighlighted([4]); - }); - - await test.step("Assign the second miner to slot 06", async () => { - await racksPage.selectRackMiner(selectedMiners[1].ipAddress); - await racksPage.clickRackSlot(6); - - await racksPage.validateMinerRowHasGreenCheck(selectedMiners[0].ipAddress); - await racksPage.validateMinerRowPosition(selectedMiners[0].ipAddress, 4); - await racksPage.validateMinerRowHasGreenCheck(selectedMiners[1].ipAddress); - await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 6); - await racksPage.validateRackSlotsHighlighted([4, 6]); - }); - - await test.step("Clear assignments and validate empty state", async () => { - await racksPage.clickClearAssignments(); - - await racksPage.validateMinerRowUnassigned(selectedMiners[0].ipAddress); - await racksPage.validateMinerRowUnassigned(selectedMiners[1].ipAddress); - await racksPage.validateRackSlotsNotHighlighted([4, 6]); - }); - - await test.step("Assign miners to slots 01 and 09 and save", async () => { - await racksPage.selectRackMiner(selectedMiners[0].ipAddress); - await racksPage.clickRackSlot(1); - await racksPage.selectRackMiner(selectedMiners[1].ipAddress); - await racksPage.clickRackSlot(9); - - await racksPage.validateMinerRowHasGreenCheck(selectedMiners[0].ipAddress); - await racksPage.validateMinerRowPosition(selectedMiners[0].ipAddress, 1); - await racksPage.validateMinerRowHasGreenCheck(selectedMiners[1].ipAddress); - await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 9); - await racksPage.validateRackSlotsHighlighted([1, 9]); - - await racksPage.clickSaveMinerPositions(); - await racksPage.validateMinerPositionsToast(rackLabel); - }); - - await test.step("Open the created rack and validate saved slots", async () => { - await racksPage.clickViewGrid(); - await racksPage.openRackCard(rackLabel, AUTOMATION_ZONE); - await racksPage.validateRackOverviewAssignedSlots([1, 9]); - await racksPage.validateRackOverviewEmptySlots([2, 3, 4, 5, 6, 7, 8]); - }); - }); + test( + "Manual rack assignment supports search, selection replacement, and saved slot state", + { tag: "@smoke" }, + async ({ racksPage }) => { + let rackLabel = ""; + let selectedMiners: RackSelectorMiner[] = []; + let selectableMinerIndexes: number[] = []; + + await test.step("Create a new 3x3 rack", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(AUTOMATION_ZONE); + + rackLabel = createRackLabel(); + await racksPage.inputRackLabel(rackLabel); + + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(LARGE_RACK_COLUMNS); + await racksPage.inputRows(LARGE_RACK_ROWS); + await racksPage.clickCreateRackFromSettings(); + }); + + await test.step("Manage miners and add the first miner to the rack list", async () => { + await racksPage.clickManageMiners(); + await racksPage.waitForMinerSelectorListToLoad(); + + selectableMinerIndexes = await racksPage.getSelectableMinerIndexes(2); + selectedMiners = await racksPage.getMinersFromSelector(selectableMinerIndexes); + test.expect(selectedMiners).toHaveLength(2); + await racksPage.selectMinersInSelectorByIndex([selectableMinerIndexes[0]]); + await racksPage.clickSaveInMinerSelector(); + }); + + await test.step("Search and assign the second miner to slot 04", async () => { + await racksPage.clickRackSlot(4); + await racksPage.clickRackSlotMenuItem("Search miners"); + await racksPage.assignSearchMinerByIpAddress(selectedMiners[1].ipAddress); + + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[1].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 4); + await racksPage.validateRackSlotsHighlighted([4]); + }); + + await test.step("Open the assigned slot while the first miner is selected", async () => { + await racksPage.selectRackMiner(selectedMiners[0].ipAddress); + await racksPage.clickRackSlot(4); + + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[1].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 4); + await racksPage.validateMinerRowUnassigned(selectedMiners[0].ipAddress); + }); + + await test.step("Replace slot 04 assignment from the list", async () => { + await racksPage.clickRackSlotMenuItem("Select from list"); + await racksPage.selectRackMiner(selectedMiners[0].ipAddress); + + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[0].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[0].ipAddress, 4); + await racksPage.validateMinerRowUnassigned(selectedMiners[1].ipAddress); + await racksPage.validateRackSlotsHighlighted([4]); + }); + + await test.step("Assign the second miner to slot 06", async () => { + await racksPage.selectRackMiner(selectedMiners[1].ipAddress); + await racksPage.clickRackSlot(6); + + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[0].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[0].ipAddress, 4); + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[1].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 6); + await racksPage.validateRackSlotsHighlighted([4, 6]); + }); + + await test.step("Clear assignments and validate empty state", async () => { + await racksPage.clickClearAssignments(); + + await racksPage.validateMinerRowUnassigned(selectedMiners[0].ipAddress); + await racksPage.validateMinerRowUnassigned(selectedMiners[1].ipAddress); + await racksPage.validateRackSlotsNotHighlighted([4, 6]); + }); + + await test.step("Assign miners to slots 01 and 09 and save", async () => { + await racksPage.selectRackMiner(selectedMiners[0].ipAddress); + await racksPage.clickRackSlot(1); + await racksPage.selectRackMiner(selectedMiners[1].ipAddress); + await racksPage.clickRackSlot(9); + + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[0].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[0].ipAddress, 1); + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[1].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 9); + await racksPage.validateRackSlotsHighlighted([1, 9]); + + await racksPage.clickSaveMinerPositions(); + await racksPage.validateMinerPositionsToast(rackLabel); + }); + + await test.step("Open the created rack and validate saved slots", async () => { + await racksPage.clickViewGrid(); + await racksPage.openRackCard(rackLabel, AUTOMATION_ZONE); + await racksPage.validateRackOverviewAssignedSlots([1, 9]); + await racksPage.validateRackOverviewEmptySlots([2, 3, 4, 5, 6, 7, 8]); + }); + }, + ); test("Rack overview search assignment updates slots and miners filter state", async ({ racksPage, minersPage }) => { let rackLabel = ""; diff --git a/client/e2eTests/protoFleet/spec/racksOverviewActions.spec.ts b/client/e2eTests/protoFleet/spec/racksOverviewActions.spec.ts index 59bb838780..f9cb22aab6 100644 --- a/client/e2eTests/protoFleet/spec/racksOverviewActions.spec.ts +++ b/client/e2eTests/protoFleet/spec/racksOverviewActions.spec.ts @@ -19,79 +19,83 @@ import { type RackSelectorMiner } from "../pages/racks"; test.describe("Racks - overview actions", () => { useRacksHooks(); - test("Rack overview actions menu manages power for assigned rig miners", async ({ racksPage, minersPage, page }) => { - let rackLabel = ""; - let selectedMiners: RackSelectorMiner[] = []; - let rackDeviceIdentifiers: string[] = []; - - await test.step("Create and save a new rack with two rig miners", async () => { - // The miner picker commits membership itself, so the rack's members - // arrive on the first AssignDevicesToRack call, not on a SaveRack. - const assignRequestPromise = page.waitForRequest(/AssignDevicesToRack/); - - await racksPage.clickAddRackButton(); - await racksPage.inputZone(AUTOMATION_ZONE); - - rackLabel = createRackLabel(); - await racksPage.inputRackLabel(rackLabel); - - await racksPage.enableCustomRackLayout(); - await racksPage.inputColumns(RACK_COLUMNS); - await racksPage.inputRows(RACK_ROWS); - await racksPage.clickCreateRackFromSettings(); - - selectedMiners = await addSelectableRigMinersToSlots(racksPage, 2, [1, 2]); - test.expect(selectedMiners).toHaveLength(2); - test.expect(selectedMiners.every((miner) => miner.model === PROTO_RIG_MODEL)).toBe(true); - - await racksPage.clickSaveMinerPositions(); - - const assignRequest = await assignRequestPromise; - rackDeviceIdentifiers = assignRequest.postDataJSON().deviceSelector.deviceList.deviceIdentifiers; - - await racksPage.validateMinerPositionsToast(rackLabel); - test.expect(rackDeviceIdentifiers).toHaveLength(2); - }); - - await test.step("Open the rack overview and validate assigned slots", async () => { - await racksPage.clickViewGrid(); - await racksPage.openRackCard(rackLabel, AUTOMATION_ZONE); - await racksPage.validateRackOverviewAssignedSlots([1, 2]); - }); - - const requestPromise = page.waitForRequest(/SetPowerTarget/); - const responsePromise = page.waitForResponse(/SetPowerTarget/); - - await test.step("Use the overview actions menu to reduce power", async () => { - await racksPage.openRackOverviewActionsMenu(); - await racksPage.clickRackOverviewManagePower(); - await minersPage.clickReducePowerOption(); - await minersPage.clickManagePowerConfirm(); - }); - - await test.step("Validate manage power toasts", async () => { - await minersPage.validateTextInToastGroup("Updating power settings"); - await minersPage.validateTextInToastGroup("Updated power settings"); - }); + test( + "Rack overview actions menu manages power for assigned rig miners", + { tag: "@smoke" }, + async ({ racksPage, minersPage, page }) => { + let rackLabel = ""; + let selectedMiners: RackSelectorMiner[] = []; + let rackDeviceIdentifiers: string[] = []; - await test.step("Validate the SetPowerTarget request targets the rack miners", async () => { - const request = await requestPromise; - const response = await responsePromise; - const requestBody = request.postDataJSON(); - const targetedDeviceIdentifiers = requestBody.deviceSelector.includeDevices.deviceIdentifiers; - const sortedTargetedDeviceIdentifiers = [...targetedDeviceIdentifiers].sort(); - const sortedRackDeviceIdentifiers = [...rackDeviceIdentifiers].sort(); - - test.expect(request.method()).toBe("POST"); - test.expect(requestBody).toHaveProperty("performanceMode"); - test.expect(requestBody.performanceMode).toBe("PERFORMANCE_MODE_EFFICIENCY"); - test.expect(requestBody).toHaveProperty("deviceSelector"); - test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); - test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); - test.expect(sortedTargetedDeviceIdentifiers).toEqual(sortedRackDeviceIdentifiers); - test.expect(response.status()).toBe(200); - }); - }); + await test.step("Create and save a new rack with two rig miners", async () => { + // The miner picker commits membership itself, so the rack's members + // arrive on the first AssignDevicesToRack call, not on a SaveRack. + const assignRequestPromise = page.waitForRequest(/AssignDevicesToRack/); + + await racksPage.clickAddRackButton(); + await racksPage.inputZone(AUTOMATION_ZONE); + + rackLabel = createRackLabel(); + await racksPage.inputRackLabel(rackLabel); + + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(RACK_COLUMNS); + await racksPage.inputRows(RACK_ROWS); + await racksPage.clickCreateRackFromSettings(); + + selectedMiners = await addSelectableRigMinersToSlots(racksPage, 2, [1, 2]); + test.expect(selectedMiners).toHaveLength(2); + test.expect(selectedMiners.every((miner) => miner.model === PROTO_RIG_MODEL)).toBe(true); + + await racksPage.clickSaveMinerPositions(); + + const assignRequest = await assignRequestPromise; + rackDeviceIdentifiers = assignRequest.postDataJSON().deviceSelector.deviceList.deviceIdentifiers; + + await racksPage.validateMinerPositionsToast(rackLabel); + test.expect(rackDeviceIdentifiers).toHaveLength(2); + }); + + await test.step("Open the rack overview and validate assigned slots", async () => { + await racksPage.clickViewGrid(); + await racksPage.openRackCard(rackLabel, AUTOMATION_ZONE); + await racksPage.validateRackOverviewAssignedSlots([1, 2]); + }); + + const requestPromise = page.waitForRequest(/SetPowerTarget/); + const responsePromise = page.waitForResponse(/SetPowerTarget/); + + await test.step("Use the overview actions menu to reduce power", async () => { + await racksPage.openRackOverviewActionsMenu(); + await racksPage.clickRackOverviewManagePower(); + await minersPage.clickReducePowerOption(); + await minersPage.clickManagePowerConfirm(); + }); + + await test.step("Validate manage power toasts", async () => { + await minersPage.validateTextInToastGroup("Updating power settings"); + await minersPage.validateTextInToastGroup("Updated power settings"); + }); + + await test.step("Validate the SetPowerTarget request targets the rack miners", async () => { + const request = await requestPromise; + const response = await responsePromise; + const requestBody = request.postDataJSON(); + const targetedDeviceIdentifiers = requestBody.deviceSelector.includeDevices.deviceIdentifiers; + const sortedTargetedDeviceIdentifiers = [...targetedDeviceIdentifiers].sort(); + const sortedRackDeviceIdentifiers = [...rackDeviceIdentifiers].sort(); + + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("performanceMode"); + test.expect(requestBody.performanceMode).toBe("PERFORMANCE_MODE_EFFICIENCY"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(sortedTargetedDeviceIdentifiers).toEqual(sortedRackDeviceIdentifiers); + test.expect(response.status()).toBe(200); + }); + }, + ); if (testConfig.target !== "real") { test("Rack overview actions menu assigns pools to assigned rig miners", async ({ @@ -177,47 +181,46 @@ test.describe("Racks - overview actions", () => { }); } - test("Rack overview actions menu opens manage security and validates password mismatch", async ({ - racksPage, - loginModal, - minersPage, - page, - }) => { - let rackLabel = ""; - - await test.step("Create a rack with two assigned Proto rigs and open the overview security flow", async () => { - await racksPage.clickAddRackButton(); - await racksPage.inputZone(AUTOMATION_ZONE); - rackLabel = createRackLabel(); - await racksPage.inputRackLabel(rackLabel); - await racksPage.enableCustomRackLayout(); - await racksPage.inputColumns(OVERVIEW_RACK_COLUMNS); - await racksPage.inputRows(OVERVIEW_RACK_ROWS); - await racksPage.clickCreateRackFromSettings(); - await addSelectableRigMinersToSlots(racksPage, 2, [1, 2]); - await racksPage.clickSaveMinerPositions(); - - await racksPage.validateMinerPositionsToast(rackLabel); - await racksPage.clickViewGrid(); - await racksPage.openRackCard(rackLabel, AUTOMATION_ZONE); - await racksPage.openRackOverviewActionsMenu(); - await racksPage.clickRackOverviewManageSecurity(); - await loginModal.loginAsAdminForSecurity(); - await minersPage.validateManageSecurityModalOpened(); - }); + test( + "Rack overview actions menu opens manage security and validates password mismatch", + { tag: "@smoke" }, + async ({ racksPage, loginModal, minersPage, page }) => { + let rackLabel = ""; - await test.step("Open the password form and validate the mismatch state", async () => { - await minersPage.clickManageSecurityUpdateButton(); - await minersPage.validateTitleInModal("Update the admin login for your miners"); - await minersPage.inputCurrentMinerPassword("root"); - await minersPage.inputNewMinerPassword("ProtoRigPass123!"); - await minersPage.inputConfirmMinerPassword("ProtoRigPass1234!"); - await minersPage.clickIn("Continue", "modal"); - await minersPage.validateTextInModal("Passwords don't match"); - - await page.getByTestId("modal").getByTestId("header-icon-button").click(); - await minersPage.closeManageSecurityModal(); - await racksPage.validateTitle(rackLabel); - }); - }); + await test.step("Create a rack with two assigned Proto rigs and open the overview security flow", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(AUTOMATION_ZONE); + rackLabel = createRackLabel(); + await racksPage.inputRackLabel(rackLabel); + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(OVERVIEW_RACK_COLUMNS); + await racksPage.inputRows(OVERVIEW_RACK_ROWS); + await racksPage.clickCreateRackFromSettings(); + await addSelectableRigMinersToSlots(racksPage, 2, [1, 2]); + await racksPage.clickSaveMinerPositions(); + + await racksPage.validateMinerPositionsToast(rackLabel); + await racksPage.clickViewGrid(); + await racksPage.openRackCard(rackLabel, AUTOMATION_ZONE); + await racksPage.openRackOverviewActionsMenu(); + await racksPage.clickRackOverviewManageSecurity(); + await loginModal.loginAsAdminForSecurity(); + await minersPage.validateManageSecurityModalOpened(); + }); + + await test.step("Open the password form and validate the mismatch state", async () => { + await minersPage.clickManageSecurityUpdateButton(); + await minersPage.validateTitleInModal("Update the admin login for your miners"); + await minersPage.inputCurrentMinerPassword("root"); + await minersPage.inputNewMinerPassword("ProtoRigPass123!"); + await minersPage.inputConfirmMinerPassword("ProtoRigPass1234!"); + await minersPage.clickIn("Continue", "modal"); + await minersPage.validateTextInModal("Passwords don't match"); + + await page.getByTestId("modal").getByTestId("header-icon-button").click(); + await minersPage.closeManageSecurityModal(); + await racksPage.validateTitle(rackLabel); + }); + }, + ); }); diff --git a/client/e2eTests/protoFleet/spec/rbac.spec.ts b/client/e2eTests/protoFleet/spec/rbac.spec.ts index 69a76506a0..5c82bc3bb0 100644 --- a/client/e2eTests/protoFleet/spec/rbac.spec.ts +++ b/client/e2eTests/protoFleet/spec/rbac.spec.ts @@ -70,25 +70,25 @@ test.describe("Proto Fleet - RBAC", () => { await page.goto("/"); }); - test("Pools read-only role cannot access the Pools settings surface", async ({ - page, - commonSteps, - settingsPoolsPage, - }) => { - await test.step("Provision a read-only pools role", async () => { - await provisionRoleAndLogin(commonSteps, { - roleDescription: "Read-only mining pool access for RBAC coverage.", - permissionKeys: ["pool:read"], + test( + "Pools read-only role cannot access the Pools settings surface", + { tag: "@smoke" }, + async ({ page, commonSteps, settingsPoolsPage }) => { + await test.step("Provision a read-only pools role", async () => { + await provisionRoleAndLogin(commonSteps, { + roleDescription: "Read-only mining pool access for RBAC coverage.", + permissionKeys: ["pool:read"], + }); }); - }); - await test.step("Validate the Pools settings surface stays inaccessible", async () => { - await validateManageOnlySettingsRouteHidden(page, { - route: "/settings/mining-pools", - validateSubmenuHidden: () => settingsPoolsPage.validateMiningPoolsSubmenuHidden(), + await test.step("Validate the Pools settings surface stays inaccessible", async () => { + await validateManageOnlySettingsRouteHidden(page, { + route: "/settings/mining-pools", + validateSubmenuHidden: () => settingsPoolsPage.validateMiningPoolsSubmenuHidden(), + }); }); - }); - }); + }, + ); test("Pools manage role can create and delete mining pools", async ({ commonSteps, @@ -325,52 +325,52 @@ test.describe("Proto Fleet - RBAC", () => { }); }); - test("Sites, buildings, and racks read-only role can view infrastructure without create actions", async ({ - commonSteps, - fleetLocationsPage, - racksPage, - }) => { - const siteName = generateRandomText(RBAC_SITE_PREFIX); - const buildingName = generateRandomText(RBAC_BUILDING_PREFIX); - const rackLabel = generateRandomText(RBAC_RACK_PREFIX); - - await test.step("Create infrastructure fixtures as admin", async () => { - await commonSteps.loginAsAdmin({ forceReauth: true }); - await createInfrastructureFixturesAsAdmin(fleetLocationsPage, racksPage, { - siteName, - buildingName, - rackLabel, + test( + "Sites, buildings, and racks read-only role can view infrastructure without create actions", + { tag: "@smoke" }, + async ({ commonSteps, fleetLocationsPage, racksPage }) => { + const siteName = generateRandomText(RBAC_SITE_PREFIX); + const buildingName = generateRandomText(RBAC_BUILDING_PREFIX); + const rackLabel = generateRandomText(RBAC_RACK_PREFIX); + + await test.step("Create infrastructure fixtures as admin", async () => { + await commonSteps.loginAsAdmin({ forceReauth: true }); + await createInfrastructureFixturesAsAdmin(fleetLocationsPage, racksPage, { + siteName, + buildingName, + rackLabel, + }); }); - }); - await test.step("Provision a read-only infrastructure role", async () => { - await provisionRoleAndLogin(commonSteps, { - roleDescription: "Read-only infrastructure access for RBAC coverage.", - permissionKeys: ["site:read", "rack:read"], + await test.step("Provision a read-only infrastructure role", async () => { + await provisionRoleAndLogin(commonSteps, { + roleDescription: "Read-only infrastructure access for RBAC coverage.", + permissionKeys: ["site:read", "rack:read"], + }); }); - }); - await test.step("Validate the infrastructure is visible without create controls", async () => { - await fleetLocationsPage.validateSiteRowCounts(siteName, { - buildings: 1, - racks: 0, - miners: 0, - }); - await fleetLocationsPage.validateBuildingRowCounts(buildingName, { - siteName, - racks: 0, - miners: 0, + await test.step("Validate the infrastructure is visible without create controls", async () => { + await fleetLocationsPage.validateSiteRowCounts(siteName, { + buildings: 1, + racks: 0, + miners: 0, + }); + await fleetLocationsPage.validateBuildingRowCounts(buildingName, { + siteName, + racks: 0, + miners: 0, + }); + await racksPage.navigateToRacksPage(); + await racksPage.clickViewList(); + await racksPage.waitForRackListToLoad({ allowEmpty: false, requireManageAccess: false }); + await racksPage.validateRackRow(rackLabel, RBAC_RACK_ZONE, 0); + await fleetLocationsPage.validateAddSiteButtonHidden(); + await fleetLocationsPage.validateAddBuildingButtonHidden(); + await racksPage.navigateToRacksPage(); + await racksPage.validateAddRackButtonHidden(); }); - await racksPage.navigateToRacksPage(); - await racksPage.clickViewList(); - await racksPage.waitForRackListToLoad({ allowEmpty: false, requireManageAccess: false }); - await racksPage.validateRackRow(rackLabel, RBAC_RACK_ZONE, 0); - await fleetLocationsPage.validateAddSiteButtonHidden(); - await fleetLocationsPage.validateAddBuildingButtonHidden(); - await racksPage.navigateToRacksPage(); - await racksPage.validateAddRackButtonHidden(); - }); - }); + }, + ); test("Sites, buildings, and racks manage role can create infrastructure", async ({ commonSteps, diff --git a/client/e2eTests/protoFleet/spec/rbacAdmin.spec.ts b/client/e2eTests/protoFleet/spec/rbacAdmin.spec.ts index 290621d073..295397c4e2 100644 --- a/client/e2eTests/protoFleet/spec/rbacAdmin.spec.ts +++ b/client/e2eTests/protoFleet/spec/rbacAdmin.spec.ts @@ -74,32 +74,31 @@ test.describe("Proto Fleet - Admin RBAC", () => { }); }); - test("Fleet-node read role can view nodes without enrollment controls", async ({ - browser, - commonSteps, - page, - settingsNodesPage, - }) => { - await test.step("Mock the Nodes backend data", async () => { - await mockReadOnlyNodes(page); - }); + test( + "Fleet-node read role can view nodes without enrollment controls", + { tag: "@smoke" }, + async ({ browser, commonSteps, page, settingsNodesPage }) => { + await test.step("Mock the Nodes backend data", async () => { + await mockReadOnlyNodes(page); + }); - await test.step("Provision a fleet-node-read role", async () => { - await provisionAdminRole(browser, test.info(), commonSteps, { - roleDescription: "View nodes without enrollment controls for RBAC coverage.", - permissionKeys: ["fleetnode:read"], + await test.step("Provision a fleet-node-read role", async () => { + await provisionAdminRole(browser, test.info(), commonSteps, { + roleDescription: "View nodes without enrollment controls for RBAC coverage.", + permissionKeys: ["fleetnode:read"], + }); }); - }); - await test.step("Open Nodes and validate management controls stay hidden", async () => { - await settingsNodesPage.navigateToNodesSettings(); - await settingsNodesPage.waitForNodesListToLoad(); - await settingsNodesPage.validateNodeVisible("node-01"); - await settingsNodesPage.validateEnrollNodeHidden(); - await settingsNodesPage.validateNodeActionHidden("Confirm enrollment"); - await settingsNodesPage.validateNodeActionHidden("Revoke"); - }); - }); + await test.step("Open Nodes and validate management controls stay hidden", async () => { + await settingsNodesPage.navigateToNodesSettings(); + await settingsNodesPage.waitForNodesListToLoad(); + await settingsNodesPage.validateNodeVisible("node-01"); + await settingsNodesPage.validateEnrollNodeHidden(); + await settingsNodesPage.validateNodeActionHidden("Confirm enrollment"); + await settingsNodesPage.validateNodeActionHidden("Revoke"); + }); + }, + ); test("Fleet-node manage role can open enrollment and confirmation controls", async ({ browser, @@ -139,33 +138,37 @@ test.describe("Proto Fleet - Admin RBAC", () => { }); }); - test("API-key manage role can create and revoke API keys", async ({ browser, commonSteps, settingsApiKeysPage }) => { - const apiKeyName = generateRandomText(ADMIN_RBAC_API_KEY_PREFIX); - - await test.step("Provision an API-key-manage role", async () => { - await provisionAdminRole(browser, test.info(), commonSteps, { - roleDescription: "Manage API keys for RBAC coverage.", - permissionKeys: ["apikey:manage"], + test( + "API-key manage role can create and revoke API keys", + { tag: "@smoke" }, + async ({ browser, commonSteps, settingsApiKeysPage }) => { + const apiKeyName = generateRandomText(ADMIN_RBAC_API_KEY_PREFIX); + + await test.step("Provision an API-key-manage role", async () => { + await provisionAdminRole(browser, test.info(), commonSteps, { + roleDescription: "Manage API keys for RBAC coverage.", + permissionKeys: ["apikey:manage"], + }); }); - }); - await test.step("Open Integrations and create an API key", async () => { - await settingsApiKeysPage.navigateToApiKeysSettings(); - await settingsApiKeysPage.validateApiKeysPageOpened(); - await settingsApiKeysPage.clickCreateApiKey(); - await settingsApiKeysPage.inputApiKeyName(apiKeyName); - await settingsApiKeysPage.clickCreateInModal(); - await settingsApiKeysPage.validateApiKeyCreated(); - await settingsApiKeysPage.clickDone(); - await settingsApiKeysPage.validateApiKeyVisible(apiKeyName); - }); + await test.step("Open Integrations and create an API key", async () => { + await settingsApiKeysPage.navigateToApiKeysSettings(); + await settingsApiKeysPage.validateApiKeysPageOpened(); + await settingsApiKeysPage.clickCreateApiKey(); + await settingsApiKeysPage.inputApiKeyName(apiKeyName); + await settingsApiKeysPage.clickCreateInModal(); + await settingsApiKeysPage.validateApiKeyCreated(); + await settingsApiKeysPage.clickDone(); + await settingsApiKeysPage.validateApiKeyVisible(apiKeyName); + }); - await test.step("Revoke the API key", async () => { - await settingsApiKeysPage.clickRevokeApiKey(apiKeyName); - await settingsApiKeysPage.confirmRevokeApiKey(); - await settingsApiKeysPage.validateApiKeyNotVisible(apiKeyName); - }); - }); + await test.step("Revoke the API key", async () => { + await settingsApiKeysPage.clickRevokeApiKey(apiKeyName); + await settingsApiKeysPage.confirmRevokeApiKey(); + await settingsApiKeysPage.validateApiKeyNotVisible(apiKeyName); + }); + }, + ); test("User-read role can list users without management controls", async ({ browser, diff --git a/client/e2eTests/protoFleet/spec/rbacMiners.spec.ts b/client/e2eTests/protoFleet/spec/rbacMiners.spec.ts index 5dcda29842..851947f630 100644 --- a/client/e2eTests/protoFleet/spec/rbacMiners.spec.ts +++ b/client/e2eTests/protoFleet/spec/rbacMiners.spec.ts @@ -35,62 +35,62 @@ test.describe("Proto Fleet - Miner RBAC", () => { await page.goto("/"); }); - test("Miners read-only role can view the miner list and status without mutating action controls", async ({ - browser, - commonSteps, - minersPage, - }) => { - let minerIp = ""; - let minerStatus = ""; - - await test.step("Prepare a visible hashing Proto rig as admin", async () => { - await commonSteps.loginAsAdmin({ forceReauth: true }); - await commonSteps.goToMinersPage(); - await ensureVisibleRigMinersAwake(minersPage); - }); + test( + "Miners read-only role can view the miner list and status without mutating action controls", + { tag: "@smoke" }, + async ({ browser, commonSteps, minersPage }) => { + let minerIp = ""; + let minerStatus = ""; + + await test.step("Prepare a visible hashing Proto rig as admin", async () => { + await commonSteps.loginAsAdmin({ forceReauth: true }); + await commonSteps.goToMinersPage(); + await ensureVisibleRigMinersAwake(minersPage); + }); - await test.step("Provision a read-only miner role", async () => { - await provisionMinerRole(browser, commonSteps, { - roleDescription: "Read-only miner access for RBAC coverage.", - permissionKeys: [...MINER_READ_PERMISSIONS], + await test.step("Provision a read-only miner role", async () => { + await provisionMinerRole(browser, commonSteps, { + roleDescription: "Read-only miner access for RBAC coverage.", + permissionKeys: [...MINER_READ_PERMISSIONS], + }); }); - }); - await test.step("Open Proto rig miners and capture a visible authenticated miner", async () => { - await commonSteps.goToMinersPage(); - await minersPage.filterRigMiners(); + await test.step("Open Proto rig miners and capture a visible authenticated miner", async () => { + await commonSteps.goToMinersPage(); + await minersPage.filterRigMiners(); - minerIp = await minersPage.getMinerIpAddressByStatus("Hashing"); - minerStatus = (await minersPage.getMinerStatus(minerIp)).trim(); + minerIp = await minersPage.getMinerIpAddressByStatus("Hashing"); + minerStatus = (await minersPage.getMinerStatus(minerIp)).trim(); - expect(minerIp).not.toBe(""); - expect(minerStatus).toBe("Hashing"); - }); + expect(minerIp).not.toBe(""); + expect(minerStatus).toBe("Hashing"); + }); - await test.step("Verify single-miner mutating controls stay hidden", async () => { - await minersPage.clickMinerThreeDotsButton(minerIp); - await minersPage.validateSingleMinerActionsHidden([ - "add-to-site-popover-button", - "add-to-building-popover-button", - "add-to-rack-popover-button", - "add-to-group-popover-button", - "blink-leds-popover-button", - "reboot-popover-button", - "shutdown-popover-button", - "wake-up-popover-button", - "manage-power-popover-button", - "mining-pool-popover-button", - "firmware-update-popover-button", - "cooling-mode-popover-button", - "download-logs-popover-button", - "rename-popover-button", - "update-worker-names-popover-button", - "security-popover-button", - "unpair-popover-button", - ]); - await minersPage.dismissSingleMinerActionsPopoverIfVisible(); - }); - }); + await test.step("Verify single-miner mutating controls stay hidden", async () => { + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.validateSingleMinerActionsHidden([ + "add-to-site-popover-button", + "add-to-building-popover-button", + "add-to-rack-popover-button", + "add-to-group-popover-button", + "blink-leds-popover-button", + "reboot-popover-button", + "shutdown-popover-button", + "wake-up-popover-button", + "manage-power-popover-button", + "mining-pool-popover-button", + "firmware-update-popover-button", + "cooling-mode-popover-button", + "download-logs-popover-button", + "rename-popover-button", + "update-worker-names-popover-button", + "security-popover-button", + "unpair-popover-button", + ]); + await minersPage.dismissSingleMinerActionsPopoverIfVisible(); + }); + }, + ); test("Miners blink-led role can blink a miner locator LED", async ({ browser, commonSteps, minersPage }) => { await test.step("Provision a blink-led miner role", async () => { @@ -193,39 +193,38 @@ test.describe("Proto Fleet - Miner RBAC", () => { } }); - test("Miners stop-mining role can open the sleep confirmation flow", async ({ - browser, - commonSteps, - minersPage, - page, - }) => { - let minerIp = ""; + test( + "Miners stop-mining role can open the sleep confirmation flow", + { tag: "@smoke" }, + async ({ browser, commonSteps, minersPage, page }) => { + let minerIp = ""; - await test.step("Prepare a hashing Proto rig", async () => { - await commonSteps.loginAsAdmin({ forceReauth: true }); - await commonSteps.goToMinersPage(); - minerIp = await selectHashingRigMinerForStopFlow(minersPage); - }); + await test.step("Prepare a hashing Proto rig", async () => { + await commonSteps.loginAsAdmin({ forceReauth: true }); + await commonSteps.goToMinersPage(); + minerIp = await selectHashingRigMinerForStopFlow(minersPage); + }); - await test.step("Provision a stop-mining miner role", async () => { - await provisionMinerRole(browser, commonSteps, { - roleDescription: "Stop miners for RBAC coverage.", - permissionKeys: [...MINER_READ_PERMISSIONS, "miner:stop_mining"], + await test.step("Provision a stop-mining miner role", async () => { + await provisionMinerRole(browser, commonSteps, { + roleDescription: "Stop miners for RBAC coverage.", + permissionKeys: [...MINER_READ_PERMISSIONS, "miner:stop_mining"], + }); }); - }); - await test.step("Open Proto rig miners", async () => { - await commonSteps.goToMinersPage(); - }); + await test.step("Open Proto rig miners", async () => { + await commonSteps.goToMinersPage(); + }); - await test.step("Open the sleep confirmation flow", async () => { - await minersPage.clickMinerThreeDotsButton(minerIp); - await minersPage.clickShutdownButton(); - await expect(page.getByTestId("shutdown-confirm-button")).toBeVisible(); - await minersPage.cancelSingleMinerConfirmationDialog(); - await minersPage.dismissSingleMinerActionsPopoverIfVisible(); - }); - }); + await test.step("Open the sleep confirmation flow", async () => { + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickShutdownButton(); + await expect(page.getByTestId("shutdown-confirm-button")).toBeVisible(); + await minersPage.cancelSingleMinerConfirmationDialog(); + await minersPage.dismissSingleMinerActionsPopoverIfVisible(); + }); + }, + ); test("Miners update-pools role can open the pool editor from a miner action menu", async ({ browser, @@ -299,31 +298,30 @@ test.describe("Proto Fleet - Miner RBAC", () => { }); }); - test("Miners delete role can open the unpair confirmation flow", async ({ - browser, - commonSteps, - minersPage, - page, - }) => { - await test.step("Provision a delete miner role", async () => { - await provisionMinerRole(browser, commonSteps, { - roleDescription: "Delete miners from fleet for RBAC coverage.", - permissionKeys: [...MINER_READ_PERMISSIONS, "miner:delete"], + test( + "Miners delete role can open the unpair confirmation flow", + { tag: "@smoke" }, + async ({ browser, commonSteps, minersPage, page }) => { + await test.step("Provision a delete miner role", async () => { + await provisionMinerRole(browser, commonSteps, { + roleDescription: "Delete miners from fleet for RBAC coverage.", + permissionKeys: [...MINER_READ_PERMISSIONS, "miner:delete"], + }); }); - }); - await test.step("Open Proto rig miners and select a miner", async () => { - await commonSteps.goToMinersPage(); - await minersPage.filterRigMiners(); - await minersPage.openSingleMinerActionsForAuthenticatedMinerWithAction("unpair-popover-button"); - }); + await test.step("Open Proto rig miners and select a miner", async () => { + await commonSteps.goToMinersPage(); + await minersPage.filterRigMiners(); + await minersPage.openSingleMinerActionsForAuthenticatedMinerWithAction("unpair-popover-button"); + }); - await test.step("Open the unpair confirmation flow", async () => { - await minersPage.clickUnpairButton(); - await expect(page.getByTestId("unpair-confirm-button")).toBeVisible(); - await minersPage.dismissModalIfVisible(); - }); - }); + await test.step("Open the unpair confirmation flow", async () => { + await minersPage.clickUnpairButton(); + await expect(page.getByTestId("unpair-confirm-button")).toBeVisible(); + await minersPage.dismissModalIfVisible(); + }); + }, + ); test("Miners cooling-mode role can open the cooling-mode flow", async ({ browser, diff --git a/client/e2eTests/protoFleet/spec/schedulesSettings.spec.ts b/client/e2eTests/protoFleet/spec/schedulesSettings.spec.ts index 6aff3d1ea3..d70d631f81 100644 --- a/client/e2eTests/protoFleet/spec/schedulesSettings.spec.ts +++ b/client/e2eTests/protoFleet/spec/schedulesSettings.spec.ts @@ -42,61 +42,65 @@ test.describe("Proto Fleet - Schedules", () => { } }); - test("Create, pause/resume, edit, and delete a schedule", async ({ commonSteps, settingsSchedulesPage }) => { - const scheduleName = generateRandomText(SCHEDULE_PREFIX); - const updatedScheduleName = `${scheduleName}_updated`; - - await test.step("Log in as admin", async () => { - await commonSteps.loginAsAdmin(); - }); - - await test.step("Navigate to schedules settings", async () => { - await settingsSchedulesPage.navigateToSchedulesSettings(); - await settingsSchedulesPage.validateSchedulesPageOpened(); - }); - - await test.step("Create a one-time schedule for one miner", async () => { - shouldCleanupSchedules = true; - await settingsSchedulesPage.clickAddSchedule(); - await settingsSchedulesPage.inputScheduleName(scheduleName); - await settingsSchedulesPage.selectStartDate(1); - await settingsSchedulesPage.openMinersTargetSelector(); - await settingsSchedulesPage.waitForMinerSelectionModalToLoad(); - await settingsSchedulesPage.selectFirstMiners(1); - await settingsSchedulesPage.confirmMinerSelection(); - await settingsSchedulesPage.clickSaveSchedule(); - }); - - await test.step("Validate the schedule was created", async () => { - await settingsSchedulesPage.validateScheduleVisible(scheduleName); - await settingsSchedulesPage.validateScheduleStatus(scheduleName, "Active"); - await settingsSchedulesPage.validateScheduleAction(scheduleName, "Set power target"); - await settingsSchedulesPage.validateScheduleTargetSummary(scheduleName, "Applies to 1 miner"); - }); - - await test.step("Pause and resume the schedule", async () => { - await settingsSchedulesPage.pauseSchedule(scheduleName); - await settingsSchedulesPage.validateScheduleStatus(scheduleName, "Paused"); - - await settingsSchedulesPage.resumeSchedule(scheduleName); - await settingsSchedulesPage.validateScheduleStatus(scheduleName, "Active"); - }); - - await test.step("Edit the schedule name", async () => { - await settingsSchedulesPage.openEditSchedule(scheduleName); - await settingsSchedulesPage.inputScheduleName(updatedScheduleName); - await settingsSchedulesPage.clickSaveSchedule(); - await settingsSchedulesPage.validateScheduleVisible(updatedScheduleName); - await settingsSchedulesPage.validateScheduleNotVisible(scheduleName); - }); - - await test.step("Delete the schedule", async () => { - await settingsSchedulesPage.deleteSchedule(updatedScheduleName); - shouldCleanupSchedules = false; - }); - }); - - test("Recurring schedule validation", async ({ commonSteps, settingsSchedulesPage }) => { + test( + "Create, pause/resume, edit, and delete a schedule", + { tag: "@smoke" }, + async ({ commonSteps, settingsSchedulesPage }) => { + const scheduleName = generateRandomText(SCHEDULE_PREFIX); + const updatedScheduleName = `${scheduleName}_updated`; + + await test.step("Log in as admin", async () => { + await commonSteps.loginAsAdmin(); + }); + + await test.step("Navigate to schedules settings", async () => { + await settingsSchedulesPage.navigateToSchedulesSettings(); + await settingsSchedulesPage.validateSchedulesPageOpened(); + }); + + await test.step("Create a one-time schedule for one miner", async () => { + shouldCleanupSchedules = true; + await settingsSchedulesPage.clickAddSchedule(); + await settingsSchedulesPage.inputScheduleName(scheduleName); + await settingsSchedulesPage.selectStartDate(1); + await settingsSchedulesPage.openMinersTargetSelector(); + await settingsSchedulesPage.waitForMinerSelectionModalToLoad(); + await settingsSchedulesPage.selectFirstMiners(1); + await settingsSchedulesPage.confirmMinerSelection(); + await settingsSchedulesPage.clickSaveSchedule(); + }); + + await test.step("Validate the schedule was created", async () => { + await settingsSchedulesPage.validateScheduleVisible(scheduleName); + await settingsSchedulesPage.validateScheduleStatus(scheduleName, "Active"); + await settingsSchedulesPage.validateScheduleAction(scheduleName, "Set power target"); + await settingsSchedulesPage.validateScheduleTargetSummary(scheduleName, "Applies to 1 miner"); + }); + + await test.step("Pause and resume the schedule", async () => { + await settingsSchedulesPage.pauseSchedule(scheduleName); + await settingsSchedulesPage.validateScheduleStatus(scheduleName, "Paused"); + + await settingsSchedulesPage.resumeSchedule(scheduleName); + await settingsSchedulesPage.validateScheduleStatus(scheduleName, "Active"); + }); + + await test.step("Edit the schedule name", async () => { + await settingsSchedulesPage.openEditSchedule(scheduleName); + await settingsSchedulesPage.inputScheduleName(updatedScheduleName); + await settingsSchedulesPage.clickSaveSchedule(); + await settingsSchedulesPage.validateScheduleVisible(updatedScheduleName); + await settingsSchedulesPage.validateScheduleNotVisible(scheduleName); + }); + + await test.step("Delete the schedule", async () => { + await settingsSchedulesPage.deleteSchedule(updatedScheduleName); + shouldCleanupSchedules = false; + }); + }, + ); + + test("Recurring schedule validation", { tag: "@smoke" }, async ({ commonSteps, settingsSchedulesPage }) => { const scheduleName = generateRandomText(SCHEDULE_PREFIX); await test.step("Log in as admin", async () => { diff --git a/client/e2eTests/protoFleet/spec/securitySettings.spec.ts b/client/e2eTests/protoFleet/spec/securitySettings.spec.ts index 624184595a..0f587f5b53 100644 --- a/client/e2eTests/protoFleet/spec/securitySettings.spec.ts +++ b/client/e2eTests/protoFleet/spec/securitySettings.spec.ts @@ -87,59 +87,63 @@ test.describe("Proto Fleet - Security Settings", () => { const newUsername = generateRandomUsername(); const newPassword = generateRandomText("A1!"); - test("Update admin username and password", async ({ authPage, commonSteps, settingsPage, settingsSecurityPage }) => { - await commonSteps.loginAsAdmin(); - - await test.step("Navigate to Security Settings", async () => { - await settingsPage.navigateToSecuritySettings(); - }); - - await test.step("Change admin username", async () => { - await settingsSecurityPage.clickUpdateUsername(); - await settingsSecurityPage.inputCurrentPassword(password); - await settingsSecurityPage.clickConfirm(); - await settingsSecurityPage.inputNewUsername(newUsername); - await settingsSecurityPage.clickConfirmUsername(); - await settingsSecurityPage.validateUsernameChangeToast(); - await settingsSecurityPage.validateUsername(newUsername); - }); - - await test.step("Log out", async () => { - await authPage.logout(); - await authPage.gotoAuthPage(); - }); - - await test.step("Log in with new username", async () => { - await authPage.inputUsername(newUsername); - await authPage.inputPassword(password); - await authPage.clickLogin(); - await authPage.validateLoggedIn(); - }); - - await test.step("Navigate to Security Settings", async () => { - await settingsPage.navigateToSecuritySettings(); - }); - - await test.step("Change admin password", async () => { - await settingsSecurityPage.clickUpdatePassword(); - await settingsSecurityPage.inputCurrentPassword(password); - await settingsSecurityPage.clickConfirm(); - await settingsSecurityPage.inputNewPassword(newPassword); - await settingsSecurityPage.inputConfirmPassword(newPassword); - await settingsSecurityPage.clickConfirmPassword(); - await settingsSecurityPage.validatePasswordChangeToast(); - }); - - await test.step("Log out", async () => { - await authPage.logout(); - await authPage.gotoAuthPage(); - }); - - await test.step("Log in with new password", async () => { - await authPage.inputUsername(newUsername); - await authPage.inputPassword(newPassword); - await authPage.clickLogin(); - await authPage.validateLoggedIn(); - }); - }); + test( + "Update admin username and password", + { tag: "@smoke" }, + async ({ authPage, commonSteps, settingsPage, settingsSecurityPage }) => { + await commonSteps.loginAsAdmin(); + + await test.step("Navigate to Security Settings", async () => { + await settingsPage.navigateToSecuritySettings(); + }); + + await test.step("Change admin username", async () => { + await settingsSecurityPage.clickUpdateUsername(); + await settingsSecurityPage.inputCurrentPassword(password); + await settingsSecurityPage.clickConfirm(); + await settingsSecurityPage.inputNewUsername(newUsername); + await settingsSecurityPage.clickConfirmUsername(); + await settingsSecurityPage.validateUsernameChangeToast(); + await settingsSecurityPage.validateUsername(newUsername); + }); + + await test.step("Log out", async () => { + await authPage.logout(); + await authPage.gotoAuthPage(); + }); + + await test.step("Log in with new username", async () => { + await authPage.inputUsername(newUsername); + await authPage.inputPassword(password); + await authPage.clickLogin(); + await authPage.validateLoggedIn(); + }); + + await test.step("Navigate to Security Settings", async () => { + await settingsPage.navigateToSecuritySettings(); + }); + + await test.step("Change admin password", async () => { + await settingsSecurityPage.clickUpdatePassword(); + await settingsSecurityPage.inputCurrentPassword(password); + await settingsSecurityPage.clickConfirm(); + await settingsSecurityPage.inputNewPassword(newPassword); + await settingsSecurityPage.inputConfirmPassword(newPassword); + await settingsSecurityPage.clickConfirmPassword(); + await settingsSecurityPage.validatePasswordChangeToast(); + }); + + await test.step("Log out", async () => { + await authPage.logout(); + await authPage.gotoAuthPage(); + }); + + await test.step("Log in with new password", async () => { + await authPage.inputUsername(newUsername); + await authPage.inputPassword(newPassword); + await authPage.clickLogin(); + await authPage.validateLoggedIn(); + }); + }, + ); }); diff --git a/client/e2eTests/protoFleet/spec/serverLogs.spec.ts b/client/e2eTests/protoFleet/spec/serverLogs.spec.ts index ed879c5ad0..b3dd8dd622 100644 --- a/client/e2eTests/protoFleet/spec/serverLogs.spec.ts +++ b/client/e2eTests/protoFleet/spec/serverLogs.spec.ts @@ -39,84 +39,88 @@ test.describe("Proto Fleet - Server Logs", () => { await page.goto("/"); }); - test("Page loads, polling appends new rows, and export starts a CSV download", async ({ - commonSteps, - page, - serverLogsPage, - }) => { - const pollSinceIds: bigint[] = []; + test( + "Page loads, polling appends new rows, and export starts a CSV download", + { tag: "@smoke" }, + async ({ commonSteps, page, serverLogsPage }) => { + const pollSinceIds: bigint[] = []; - await page.route(serverLogsRpcPattern, async (route) => { - const request = parseServerLogsRequest(route); - - if (request.limit === 5000) { - return fulfillServerLogs(route, [...initialEntries, appendedEntry], 3n); - } + await page.route(serverLogsRpcPattern, async (route) => { + const request = parseServerLogsRequest(route); - pollSinceIds.push(request.sinceId); + if (request.limit === 5000) { + return fulfillServerLogs(route, [...initialEntries, appendedEntry], 3n); + } - if (request.sinceId === 0n) { - return fulfillServerLogs(route, initialEntries, 2n); - } + pollSinceIds.push(request.sinceId); - if (request.sinceId === 2n) { - return fulfillServerLogs(route, [appendedEntry], 3n); - } - - return fulfillServerLogs(route, [], 3n); - }); + if (request.sinceId === 0n) { + return fulfillServerLogs(route, initialEntries, 2n); + } - await commonSteps.loginAsAdmin(); + if (request.sinceId === 2n) { + return fulfillServerLogs(route, [appendedEntry], 3n); + } - await test.step("Open Server Logs and validate the initial render", async () => { - await serverLogsPage.navigateToServerLogsSettings(); - await serverLogsPage.validateServerLogsPageOpened(); - await serverLogsPage.waitForLogRowCount(2); - await serverLogsPage.validateLogRowVisible("fleetd server booted"); - await serverLogsPage.validateLogRowVisible("http request completed request_id=req-123"); - expect(pollSinceIds[0]).toBe(0n); - }); + return fulfillServerLogs(route, [], 3n); + }); - await test.step("Wait for the next poll to append a new log row", async () => { - await serverLogsPage.waitForLogRowCount(3); - await serverLogsPage.validateLogRowVisible("scheduler background sweep failed job=retention"); - expect(pollSinceIds.slice(0, 2)).toEqual([0n, 2n]); - }); + await commonSteps.loginAsAdmin(); - await test.step("Export the buffered logs and validate the download starts", async () => { - const exportRequestPromise = page.waitForRequest((request) => { - if (!request.url().match(serverLogsRpcPattern)) { - return false; - } + await test.step("Open Server Logs and validate the initial render", async () => { + await serverLogsPage.navigateToServerLogsSettings(); + await serverLogsPage.validateServerLogsPageOpened(); + await serverLogsPage.waitForLogRowCount(2); + await serverLogsPage.validateLogRowVisible("fleetd server booted"); + await serverLogsPage.validateLogRowVisible("http request completed request_id=req-123"); + expect(pollSinceIds[0]).toBe(0n); + }); - const payload = fromJsonString(ListServerLogsRequestSchema, request.postData() ?? "{}"); - return payload.limit === 5000 && payload.sinceId === 0n; + await test.step("Wait for the next poll to append a new log row", async () => { + await serverLogsPage.waitForLogRowCount(3); + await serverLogsPage.validateLogRowVisible("scheduler background sweep failed job=retention"); + expect(pollSinceIds.slice(0, 2)).toEqual([0n, 2n]); }); - const downloadPromise = page.waitForEvent("download"); - await serverLogsPage.clickExport(); + await test.step("Export the buffered logs and validate the download starts", async () => { + const exportRequestPromise = page.waitForRequest((request) => { + if (!request.url().match(serverLogsRpcPattern)) { + return false; + } - await exportRequestPromise; - const download = await downloadPromise; - expect(download.suggestedFilename()).toMatch(/server-logs.*\.csv$/i); - }); - }); + const payload = fromJsonString(ListServerLogsRequestSchema, request.postData() ?? "{}"); + return payload.limit === 5000 && payload.sinceId === 0n; + }); + const downloadPromise = page.waitForEvent("download"); - test("Load failures surface the server logs error callout", async ({ commonSteps, page, serverLogsPage }) => { - await page.route(serverLogsRpcPattern, async (route) => { - return route.fulfill({ - status: 503, - contentType: "application/json", - body: JSON.stringify({ code: "unavailable", message: loadErrorMessage }), + await serverLogsPage.clickExport(); + + await exportRequestPromise; + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/server-logs.*\.csv$/i); + }); + }, + ); + + test( + "Load failures surface the server logs error callout", + { tag: "@smoke" }, + async ({ commonSteps, page, serverLogsPage }) => { + await page.route(serverLogsRpcPattern, async (route) => { + return route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ code: "unavailable", message: loadErrorMessage }), + }); }); - }); - await commonSteps.loginAsAdmin(); + await commonSteps.loginAsAdmin(); - await serverLogsPage.navigateToServerLogsSettings(); - await serverLogsPage.validateServerLogsPageOpened(); - await serverLogsPage.validateFetchErrorCallout(loadErrorMessage); - }); + await serverLogsPage.navigateToServerLogsSettings(); + await serverLogsPage.validateServerLogsPageOpened(); + await serverLogsPage.validateFetchErrorCallout(loadErrorMessage); + }, + ); test("Export failures surface the export error callout", async ({ commonSteps, page, serverLogsPage }) => { await page.route(serverLogsRpcPattern, async (route) => { diff --git a/client/e2eTests/protoFleet/spec/singleMinerView.spec.ts b/client/e2eTests/protoFleet/spec/singleMinerView.spec.ts index 9417d1c40c..056ad165ec 100644 --- a/client/e2eTests/protoFleet/spec/singleMinerView.spec.ts +++ b/client/e2eTests/protoFleet/spec/singleMinerView.spec.ts @@ -6,43 +6,43 @@ test.describe("Proto Fleet - Single Miner View", () => { await page.goto("/"); }); - test("opens an embedded miner from fleet, navigates within the hosted view, and returns to the fleet list", async ({ - commonSteps, - minersPage, - singleMinerPage, - }) => { - let miner: { name: string; ipAddress: string }; - - await test.step("Open the fleet miners list and pick a Proto rig", async () => { - await commonSteps.loginAsAdmin({ forceReauth: true }); - await commonSteps.goToMinersPage(); - await minersPage.filterRigMiners(); - [miner] = await minersPage.getVisibleMinerSummaries(1); - }); + test( + "opens an embedded miner from fleet, navigates within the hosted view, and returns to the fleet list", + { tag: "@smoke" }, + async ({ commonSteps, minersPage, singleMinerPage }) => { + let miner: { name: string; ipAddress: string }; + + await test.step("Open the fleet miners list and pick a Proto rig", async () => { + await commonSteps.loginAsAdmin({ forceReauth: true }); + await commonSteps.goToMinersPage(); + await minersPage.filterRigMiners(); + [miner] = await minersPage.getVisibleMinerSummaries(1); + }); - await test.step("Open the embedded miner view from the fleet list", async () => { - await minersPage.openMinerRow(miner.ipAddress); - await singleMinerPage.validateSingleMinerSurfaceOpened(); - await singleMinerPage.validateCurrentSubRoute("hashrate"); - await singleMinerPage.validateCloseButtonLabel(miner.name); - await singleMinerPage.validateHostedMetadata({ - minerName: miner.name, - ipAddress: miner.ipAddress, + await test.step("Open the embedded miner view from the fleet list", async () => { + await minersPage.openMinerRow(miner.ipAddress); + await singleMinerPage.validateSingleMinerSurfaceOpened(); + await singleMinerPage.validateCurrentSubRoute("hashrate"); + await singleMinerPage.validateCloseButtonLabel(miner.name); + await singleMinerPage.validateHostedMetadata({ + minerName: miner.name, + ipAddress: miner.ipAddress, + }); }); - }); - await test.step("Navigate to a second embedded page and keep the miner route scoped", async () => { - await singleMinerPage.navigateToLogs(); - await singleMinerPage.validateCurrentSubRoute("logs"); - }); + await test.step("Navigate to a second embedded page and keep the miner route scoped", async () => { + await singleMinerPage.navigateToLogs(); + await singleMinerPage.validateCurrentSubRoute("logs"); + }); - await test.step("Close the embedded view and return to the fleet list", async () => { - await singleMinerPage.clickCloseButton(); - await minersPage.waitForMinersTitle(); - await minersPage.waitForMinersListToLoad(); - await minersPage.validateMinerInList(miner.ipAddress); - }); - }); + await test.step("Close the embedded view and return to the fleet list", async () => { + await singleMinerPage.clickCloseButton(); + await minersPage.waitForMinersTitle(); + await minersPage.waitForMinersListToLoad(); + await minersPage.validateMinerInList(miner.ipAddress); + }); + }, + ); test("switching directly between embedded miner routes resets page-local state for the new miner", async ({ commonSteps, @@ -125,31 +125,31 @@ test.describe("Proto Fleet - Single Miner View", () => { }); }); - test("fleet-hosted miner routes can open authentication settings without surfacing the direct ProtoOS login modal", async ({ - commonSteps, - minersPage, - singleMinerPage, - }) => { - let miner: { name: string; ipAddress: string }; - - await test.step("Open an embedded Proto rig from the fleet miners list", async () => { - await commonSteps.loginAsAdmin({ forceReauth: true }); - await commonSteps.goToMinersPage(); - await minersPage.filterRigMiners(); - [miner] = await minersPage.getVisibleMinerSummaries(1); - await minersPage.openMinerRow(miner.ipAddress); - await singleMinerPage.validateCurrentSubRoute("hashrate"); - }); + test( + "fleet-hosted miner routes can open authentication settings without surfacing the direct ProtoOS login modal", + { tag: "@smoke" }, + async ({ commonSteps, minersPage, singleMinerPage }) => { + let miner: { name: string; ipAddress: string }; + + await test.step("Open an embedded Proto rig from the fleet miners list", async () => { + await commonSteps.loginAsAdmin({ forceReauth: true }); + await commonSteps.goToMinersPage(); + await minersPage.filterRigMiners(); + [miner] = await minersPage.getVisibleMinerSummaries(1); + await minersPage.openMinerRow(miner.ipAddress); + await singleMinerPage.validateCurrentSubRoute("hashrate"); + }); - await test.step("Open the authentication settings route inside the hosted miner view", async () => { - await singleMinerPage.navigateToAuthenticationSettings(); - await singleMinerPage.validateAuthenticationSettingsPageOpened(); - await singleMinerPage.validateDirectLoginModalHidden(); - await singleMinerPage.validateCloseButtonLabel(miner.name); - await singleMinerPage.validateHostedMetadata({ - minerName: miner.name, - ipAddress: miner.ipAddress, + await test.step("Open the authentication settings route inside the hosted miner view", async () => { + await singleMinerPage.navigateToAuthenticationSettings(); + await singleMinerPage.validateAuthenticationSettingsPageOpened(); + await singleMinerPage.validateDirectLoginModalHidden(); + await singleMinerPage.validateCloseButtonLabel(miner.name); + await singleMinerPage.validateHostedMetadata({ + minerName: miner.name, + ipAddress: miner.ipAddress, + }); }); - }); - }); + }, + ); }); diff --git a/client/e2eTests/protoFleet/spec/sitesDetail.spec.ts b/client/e2eTests/protoFleet/spec/sitesDetail.spec.ts index bfa319b5cb..048073e7cb 100644 --- a/client/e2eTests/protoFleet/spec/sitesDetail.spec.ts +++ b/client/e2eTests/protoFleet/spec/sitesDetail.spec.ts @@ -8,44 +8,46 @@ import { test.describe("Sites - detail", () => { useSiteDetailHooks(); - test("Site detail supports editing details, adding a building, and switching to a sibling site", async ({ - fleetLocationsPage, - }, testInfo) => { - const scenario = createSiteDetailScenarioData(testInfo); - - await createSiteDetailSites(fleetLocationsPage, scenario); - await fleetLocationsPage.openSiteDetail(scenario.siteName); - await fleetLocationsPage.validateSiteDetailOpened(scenario.siteName); - await fleetLocationsPage.validateSiteDetailMetrics({ location: "—", buildings: 0 }); - - await fleetLocationsPage.editSiteDetailsFromDetail({ - name: scenario.renamedSiteName, - city: scenario.city, - powerCapacityMw: scenario.powerCapacityMw, - }); - - await fleetLocationsPage.validateSiteDetailOpened(scenario.renamedSiteName); - await fleetLocationsPage.validateSiteDetailMetrics({ location: scenario.city, buildings: 0 }); - - await fleetLocationsPage.addBuildingFromSiteDetail(scenario.buildingName); - - await fleetLocationsPage.validateSiteDetailMetrics({ location: scenario.city, buildings: 1 }); - await fleetLocationsPage.validateSiteDetailBuildingVisible(scenario.buildingName); - await fleetLocationsPage.switchSiteDetailBreadcrumbTo(scenario.siblingSiteName); - await fleetLocationsPage.validateSiteDetailOpened(scenario.siblingSiteName); - await fleetLocationsPage.validateSiteDetailMetrics({ location: "—", buildings: 0 }); - - await fleetLocationsPage.validateSiteRowCounts(scenario.renamedSiteName, { - buildings: 1, - racks: 0, - miners: 0, - }); - await fleetLocationsPage.validateBuildingRowCounts(scenario.buildingName, { - siteName: scenario.renamedSiteName, - racks: 0, - miners: 0, - }); - }); + test( + "Site detail supports editing details, adding a building, and switching to a sibling site", + { tag: "@smoke" }, + async ({ fleetLocationsPage }, testInfo) => { + const scenario = createSiteDetailScenarioData(testInfo); + + await createSiteDetailSites(fleetLocationsPage, scenario); + await fleetLocationsPage.openSiteDetail(scenario.siteName); + await fleetLocationsPage.validateSiteDetailOpened(scenario.siteName); + await fleetLocationsPage.validateSiteDetailMetrics({ location: "—", buildings: 0 }); + + await fleetLocationsPage.editSiteDetailsFromDetail({ + name: scenario.renamedSiteName, + city: scenario.city, + powerCapacityMw: scenario.powerCapacityMw, + }); + + await fleetLocationsPage.validateSiteDetailOpened(scenario.renamedSiteName); + await fleetLocationsPage.validateSiteDetailMetrics({ location: scenario.city, buildings: 0 }); + + await fleetLocationsPage.addBuildingFromSiteDetail(scenario.buildingName); + + await fleetLocationsPage.validateSiteDetailMetrics({ location: scenario.city, buildings: 1 }); + await fleetLocationsPage.validateSiteDetailBuildingVisible(scenario.buildingName); + await fleetLocationsPage.switchSiteDetailBreadcrumbTo(scenario.siblingSiteName); + await fleetLocationsPage.validateSiteDetailOpened(scenario.siblingSiteName); + await fleetLocationsPage.validateSiteDetailMetrics({ location: "—", buildings: 0 }); + + await fleetLocationsPage.validateSiteRowCounts(scenario.renamedSiteName, { + buildings: 1, + racks: 0, + miners: 0, + }); + await fleetLocationsPage.validateBuildingRowCounts(scenario.buildingName, { + siteName: scenario.renamedSiteName, + racks: 0, + miners: 0, + }); + }, + ); test("Deleting a site from the detail page removes it", async ({ fleetLocationsPage }, testInfo) => { const scenario = createSiteDetailScenarioData(testInfo); diff --git a/client/e2eTests/protoFleet/spec/teamAccounts.spec.ts b/client/e2eTests/protoFleet/spec/teamAccounts.spec.ts index 2016485035..b2a8c6323d 100644 --- a/client/e2eTests/protoFleet/spec/teamAccounts.spec.ts +++ b/client/e2eTests/protoFleet/spec/teamAccounts.spec.ts @@ -60,7 +60,7 @@ test.describe("Proto Fleet - Team Accounts", () => { } }); - test("Add team member", async ({ settingsPage, settingsTeamPage, commonSteps }) => { + test("Add team member", { tag: "@smoke" }, async ({ settingsPage, settingsTeamPage, commonSteps }) => { await test.step("Log in as admin", async () => { await commonSteps.loginAsAdmin(); }); @@ -125,7 +125,7 @@ test.describe("Proto Fleet - Team Accounts", () => { }); }); - test("New member log in", async ({ authPage, settingsPage, settingsTeamPage, commonSteps }) => { + test("New member log in", { tag: "@smoke" }, async ({ authPage, settingsPage, settingsTeamPage, commonSteps }) => { let username = generateRandomUsername(); let tempPassword: string; diff --git a/client/vite.config.ts b/client/vite.config.ts index 9a3fb9fbb0..fdb4204783 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -13,6 +13,7 @@ const _dirname = __dirname; const src = resolve(_dirname, "src"); const MODES = ["protoFleet", "protoOS"]; +const DOCKER_PREVIEW_ALLOWED_HOSTS = ["host.docker.internal"]; const createModeConfig = (mode) => { return { @@ -201,6 +202,7 @@ export default defineConfig(({ mode, command }) => { }, preview: { proxy: proxies, + allowedHosts: DOCKER_PREVIEW_ALLOWED_HOSTS, }, }; });