diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 9b01cc857..4baf0a663 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -18,6 +18,7 @@ jobs: - uses: actions/setup-go@v5 with: go-version: "1.24.0" + cache: false # avoid cache thrashing id: go - uses: actions/cache@v4 @@ -222,6 +223,7 @@ jobs: - name: Run tests run: npx playwright test + timeout-minutes: 20 env: TZ: Europe/Berlin diff --git a/tests/auth.spec.ts b/tests/auth.spec.ts index de0688a5e..2707470d6 100644 --- a/tests/auth.spec.ts +++ b/tests/auth.spec.ts @@ -1,6 +1,11 @@ import { test, expect } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; -import { expectModalHidden, expectModalVisible } from "./utils"; +import { + expectModalHidden, + expectModalVisible, + openTopNavigation, + expectTopNavigationClosed, +} from "./utils"; test.use({ baseURL: baseUrl() }); const BASIC = "basics.evcc.yaml"; @@ -38,8 +43,9 @@ test("login", async ({ page }) => { await page.goto("/"); // go to config - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await page.getByRole("link", { name: "Configuration" }).click(); + await expectTopNavigationClosed(page); // login modal const login = page.getByTestId("login-modal"); @@ -65,8 +71,9 @@ test("http iframe hint", async ({ page }) => { await page.goto("/"); // go to config - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await page.getByRole("link", { name: "Configuration" }).click(); + await expectTopNavigationClosed(page); // login modal const login = page.getByTestId("login-modal"); @@ -116,13 +123,15 @@ test("update password", async ({ page }) => { ).not.toBeVisible(); // logout - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await page.getByRole("button", { name: "Logout" }).click(); + await expectTopNavigationClosed(page); // login modal - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await expect(page.getByRole("button", { name: "Logout" })).not.toBeVisible(); await page.getByRole("link", { name: "Configuration" }).click(); + await expectTopNavigationClosed(page); const loginNew = page.getByTestId("login-modal"); await expectModalVisible(loginNew); await loginNew.getByLabel("Password").fill(newPassword); @@ -153,8 +162,9 @@ test("disable auth", async ({ page }) => { await expectModalHidden(modal); // configuration page without login - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await page.getByRole("link", { name: "Configuration" }).click(); + await expectTopNavigationClosed(page); await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible(); await stop(); diff --git a/tests/battery-settings.spec.ts b/tests/battery-settings.spec.ts index 0b536edb2..f25715417 100644 --- a/tests/battery-settings.spec.ts +++ b/tests/battery-settings.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; -import { expectModalVisible, expectModalHidden } from "./utils"; +import { expectModalVisible, expectModalHidden, openTopNavigation } from "./utils"; test.use({ baseURL: baseUrl() }); test.beforeAll(async () => { @@ -13,10 +13,11 @@ test.afterAll(async () => { test.describe("battery settings", async () => { test("open modal", async ({ page }) => { await page.goto("/"); - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await page.getByTestId("topnavigation-battery").click(); const modal = page.getByTestId("battery-settings-modal"); + await expectModalVisible(modal); await expect(modal.getByRole("heading", { name: "Home Battery" })).toBeVisible(); await expect(modal.getByRole("link", { name: "Grid charging 🧪" })).not.toBeVisible(); await expect(modal).toContainText("Battery level: 50%"); @@ -25,20 +26,22 @@ test.describe("battery settings", async () => { test("battery usage", async ({ page }) => { await page.goto("/"); - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await page.getByTestId("topnavigation-battery").click(); - await page.locator("#batterySettingsPriority").selectOption({ label: "50%" }); - await expect(page.locator("label[for=batterySettingsPriorityMiddle] span")).toHaveText("50%"); - await expect(page.locator("label[for=batterySettingsPriorityBottom] span")).toHaveText("50%"); - await page.locator("#batterySettingsBufferTop").selectOption({ label: "80%" }); - await page.locator("#batterySettingsBufferStart").selectOption({ label: "when above 90%." }); - await expect(page.locator("label[for=batterySettingsBuffer] span")).toHaveText("80%"); + const modal = page.getByTestId("battery-settings-modal"); + await expectModalVisible(modal); + await modal.locator("#batterySettingsPriority").selectOption({ label: "50%" }); + await expect(modal.locator("label[for=batterySettingsPriorityMiddle] span")).toHaveText("50%"); + await expect(modal.locator("label[for=batterySettingsPriorityBottom] span")).toHaveText("50%"); + await modal.locator("#batterySettingsBufferTop").selectOption({ label: "80%" }); + await modal.locator("#batterySettingsBufferStart").selectOption({ label: "when above 90%." }); + await expect(modal.locator("label[for=batterySettingsBuffer] span")).toHaveText("80%"); }); test("grid charging", async ({ page }) => { await page.goto("/"); - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await page.getByTestId("topnavigation-battery").click(); const modal = page.getByTestId("battery-settings-modal"); await expectModalVisible(modal); diff --git a/tests/config-grid.spec.ts b/tests/config-grid.spec.ts index ed524f8d4..3994d5e89 100644 --- a/tests/config-grid.spec.ts +++ b/tests/config-grid.spec.ts @@ -1,6 +1,12 @@ import { test, expect } from "@playwright/test"; import { start, stop, restart, baseUrl } from "./evcc"; -import { startSimulator, stopSimulator, simulatorUrl, simulatorHost } from "./simulator"; +import { + startSimulator, + stopSimulator, + simulatorUrl, + simulatorHost, + simulatorApply, +} from "./simulator"; import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils"; const CONFIG_ONE_LP = "config-one-lp.evcc.yaml"; @@ -29,7 +35,7 @@ test.describe("grid meter", async () => { // setup test data for mock openems api await page.goto(simulatorUrl()); await page.getByLabel("Grid Power").fill("5000"); - await page.getByRole("button", { name: "Apply changes" }).click(); + await simulatorApply(page); await page.goto("/#/config"); await enableExperimental(page, false); diff --git a/tests/config-loadpoint.spec.ts b/tests/config-loadpoint.spec.ts index eb53c0a95..e6d926d9a 100644 --- a/tests/config-loadpoint.spec.ts +++ b/tests/config-loadpoint.spec.ts @@ -279,6 +279,8 @@ test.describe("charging loadpoint", async () => { await addDemoCharger(page); await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_2); await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + await expect(page.getByTestId("loadpoint")).toHaveCount(2); // restart await restart(); @@ -320,6 +322,9 @@ test.describe("charging loadpoint", async () => { await page.goto("/"); await expect(page.getByRole("button", { name: "Off" })).toHaveClass(/active/); await page.getByRole("button", { name: "Solar", exact: true }).click(); + await page.waitForLoadState("networkidle"); + await expect(page.getByRole("button", { name: "Solar", exact: true })).toHaveClass(/active/); + await restart(); await page.reload(); await expect(page.getByRole("button", { name: "Solar", exact: true })).toHaveClass(/active/); diff --git a/tests/config.spec.ts b/tests/config.spec.ts index 5edf2b7a3..34cc64501 100644 --- a/tests/config.spec.ts +++ b/tests/config.spec.ts @@ -1,6 +1,12 @@ import { test, expect } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; -import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils"; +import { + enableExperimental, + expectModalHidden, + expectModalVisible, + openTopNavigation, + expectTopNavigationClosed, +} from "./utils"; const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml"; @@ -16,8 +22,9 @@ test.afterAll(async () => { test.describe("basics", async () => { test("navigation to config", async ({ page }) => { await page.goto("/"); - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await page.getByRole("link", { name: "Configuration" }).click(); + await expectTopNavigationClosed(page); await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible(); }); }); diff --git a/tests/demo.spec.ts b/tests/demo.spec.ts index 98a35e7e6..81d9a2c8c 100644 --- a/tests/demo.spec.ts +++ b/tests/demo.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; -import { expectModalHidden } from "./utils"; +import { expectModalHidden, openTopNavigation, expectTopNavigationClosed } from "./utils"; import { ChildProcess } from "child_process"; test.use({ baseURL: baseUrl() }); @@ -35,8 +35,9 @@ test.describe("demo mode", async () => { }); test("auth is locked", async ({ page }) => { - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await page.getByRole("link", { name: "Configuration" }).click(); + await expectTopNavigationClosed(page); const loginModal = page.getByTestId("login-modal"); await expect(loginModal).toBeVisible(); await expect(loginModal).toContainText("Login is not supported in demo mode."); diff --git a/tests/limits.spec.ts b/tests/limits.spec.ts index 6ddd51200..76a246158 100644 --- a/tests/limits.spec.ts +++ b/tests/limits.spec.ts @@ -1,6 +1,12 @@ import { test, expect } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; -import { startSimulator, stopSimulator, simulatorUrl, simulatorConfig } from "./simulator"; +import { + startSimulator, + stopSimulator, + simulatorUrl, + simulatorConfig, + simulatorApply, +} from "./simulator"; test.use({ baseURL: baseUrl() }); @@ -18,7 +24,7 @@ test.beforeEach(async ({ page }) => { await page.getByLabel("Grid Power").fill("500"); await page.getByTestId("vehicle0").getByLabel("SoC").fill("20"); await page.getByTestId("loadpoint0").getByText("B (connected)").click(); - await page.getByRole("button", { name: "Apply changes" }).click(); + await simulatorApply(page); }); test.afterEach(async () => { @@ -41,7 +47,7 @@ test.describe("limitSoc", async () => { test("can be set even if vehicle isn't connected yet", async ({ page }) => { await page.goto(simulatorUrl()); await page.getByTestId("loadpoint0").getByText("A (disconnected)").click(); - await page.getByRole("button", { name: "Apply changes" }).click(); + await simulatorApply(page); await page.goto("/"); await expect(page.getByTestId("vehicle-title")).toContainText("blauer e-Golf"); @@ -52,7 +58,7 @@ test.describe("limitSoc", async () => { await page.goto(simulatorUrl()); await page.getByTestId("loadpoint0").getByText("B (connected)").click(); - await page.getByRole("button", { name: "Apply changes" }).click(); + await simulatorApply(page); await page.goto("/"); await expect(page.getByTestId("limit-soc-value")).toHaveText("50%"); @@ -67,7 +73,7 @@ test.describe("limitSoc", async () => { // disconnect await page.goto(simulatorUrl()); await page.getByTestId("loadpoint0").getByText("A (disconnected)").click(); - await page.getByRole("button", { name: "Apply changes" }).click(); + await simulatorApply(page); await page.goto("/"); await expect(page.getByTestId("vehicle-status")).toHaveText("Disconnected."); @@ -76,7 +82,7 @@ test.describe("limitSoc", async () => { // connect await page.goto(simulatorUrl()); await page.getByTestId("loadpoint0").getByText("B (connected)").click(); - await page.getByRole("button", { name: "Apply changes" }).click(); + await simulatorApply(page); await page.goto("/"); await expect(page.getByTestId("vehicle-status")).toHaveText("Connected."); diff --git a/tests/logs.spec.ts b/tests/logs.spec.ts index b92af0cc6..35df7fd20 100644 --- a/tests/logs.spec.ts +++ b/tests/logs.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; +import { openTopNavigation, expectTopNavigationClosed } from "./utils"; test.use({ baseURL: baseUrl() }); @@ -13,11 +14,19 @@ test.afterAll(async () => { test.describe("opening logs", async () => { test("via config", async ({ page }) => { await page.goto("/"); - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await page.getByRole("link", { name: "Configuration" }).click(); + await expectTopNavigationClosed(page); await page.getByRole("link", { name: "Logs" }).click(); await expect(page.getByRole("heading", { name: "Logs", exact: false })).toBeVisible(); }); + test("via top navigation", async ({ page }) => { + await page.goto("/"); + await openTopNavigation(page); + await page.getByRole("link", { name: "Logs" }).click(); + await expectTopNavigationClosed(page); + await expect(page.getByRole("heading", { name: "Logs", exact: false })).toBeVisible(); + }); test("via notifications", async ({ page }) => { await page.goto("/"); await page.evaluate(() => window.app.raise({ message: "Fake Error" })); @@ -27,8 +36,9 @@ test.describe("opening logs", async () => { }); test("via need help", async ({ page }) => { await page.goto("/"); - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await page.getByRole("button", { name: "Need Help?" }).click(); + await expectTopNavigationClosed(page); await page.getByRole("link", { name: "View logs" }).click(); await expect(page.getByRole("heading", { name: "Logs", exact: false })).toBeVisible(); }); diff --git a/tests/modals.spec.ts b/tests/modals.spec.ts index d956cfd6e..353e31968 100644 --- a/tests/modals.spec.ts +++ b/tests/modals.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; import { startSimulator, stopSimulator, simulatorConfig } from "./simulator"; +import { openTopNavigation, expectTopNavigationClosed, closeTopNavigation } from "./utils"; const BASICS_CONFIG = "basics.evcc.yaml"; @@ -28,7 +29,7 @@ test.describe("Basics", async () => { await page.goto(route.path); await expect(page.getByRole("heading", { name: route.title || title })).toBeVisible(); - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await expect(page.getByRole("button", { name: "User Interface" })).toBeVisible(); await expect(page.getByRole("button", { name: "Home Battery" })).not.toBeVisible(); await expect(page.getByRole("button", { name: "Need help?" })).toBeVisible(); @@ -39,8 +40,9 @@ test.describe("Basics", async () => { await page.goto("/"); await expect(page.getByRole("heading", { name: "Hello World" })).toBeVisible(); - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await page.getByRole("button", { name: "Need help?" }).click(); + await expectTopNavigationClosed(page); await expect(page.getByRole("heading", { name: "Need help?" })).toBeVisible(); }); @@ -48,8 +50,9 @@ test.describe("Basics", async () => { test("User Interface", async ({ page }) => { await page.goto("/"); await expect(page.getByRole("heading", { name: "Hello World" })).toBeVisible(); - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await page.getByRole("button", { name: "User Interface" }).click(); + await expectTopNavigationClosed(page); await expect(page.getByRole("heading", { name: "User Interface" })).toBeVisible(); }); @@ -73,18 +76,20 @@ test.describe("Advanced", async () => { await page.goto(route.path); await expect(page.getByRole("heading", { name: route.title || title })).toBeVisible(); - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await expect(page.getByRole("button", { name: "User Interface" })).toBeVisible(); await expect(page.getByRole("button", { name: "Home Battery" })).toBeVisible(); await expect(page.getByRole("button", { name: "Need help?" })).toBeVisible(); + await closeTopNavigation(page); } }); test("Home Battery from top navigation", async ({ page }) => { await page.goto("/"); await expect(page.getByRole("heading", { name: title })).toBeVisible(); - await page.getByTestId("topnavigation-button").click(); + await openTopNavigation(page); await page.getByRole("button", { name: "Home Battery" }).click(); + await expectTopNavigationClosed(page); await expect(page.getByRole("heading", { name: "Home Battery" })).toBeVisible(); }); diff --git a/tests/sessions.spec.ts b/tests/sessions.spec.ts index 7632fbe8d..7d8097b21 100644 --- a/tests/sessions.spec.ts +++ b/tests/sessions.spec.ts @@ -1,6 +1,11 @@ import { test, expect, devices, type Page } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; -import { expectModalVisible, expectModalHidden } from "./utils"; +import { + expectModalVisible, + expectModalHidden, + openTopNavigation, + expectTopNavigationClosed, +} from "./utils"; test.use({ baseURL: baseUrl() }); @@ -33,12 +38,12 @@ async function selectVehicleFilter(page: Page, value: string) { test.describe("basics", async () => { test("navigation to sessions", async ({ page }) => { await page.goto("/"); - const topNavigationButton = await page.getByTestId("topnavigation-button"); - await topNavigationButton.click(); + await openTopNavigation(page); await page .getByTestId("topnavigation-dropdown") .getByRole("link", { name: "Charging sessions" }) .click(); + await expectTopNavigationClosed(page); await expect(page.getByRole("heading", { name: "Charging Sessions" })).toBeVisible(); }); test("month without data", async ({ page }) => { diff --git a/tests/simulator.ts b/tests/simulator.ts index a2aa2ed23..be198ca6e 100644 --- a/tests/simulator.ts +++ b/tests/simulator.ts @@ -5,6 +5,7 @@ import waitOn from "wait-on"; import axios from "axios"; import { spawn } from "child_process"; import { Transform } from "stream"; +import type { Page } from "@playwright/test"; const LOG_ENABLED = false; @@ -80,3 +81,12 @@ export async function stopSimulator() { log(`wait until port ${port} is closed`); await waitOn({ resources: [`tcp:localhost:${port}`], reverse: true, log: LOG_ENABLED }); } + +export const simulatorApply = async (page: Page) => { + await Promise.all([ + page.waitForResponse( + (response) => response.url().includes("/api/state") && response.request().method() === "POST" + ), + page.getByRole("button", { name: "Apply changes" }).click(), + ]); +}; diff --git a/tests/smart-cost.spec.ts b/tests/smart-cost.spec.ts index 264b8ef89..211de7b76 100644 --- a/tests/smart-cost.spec.ts +++ b/tests/smart-cost.spec.ts @@ -1,6 +1,12 @@ import { test, expect } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; -import { startSimulator, stopSimulator, simulatorUrl, simulatorConfig } from "./simulator"; +import { + startSimulator, + stopSimulator, + simulatorUrl, + simulatorConfig, + simulatorApply, +} from "./simulator"; test.use({ baseURL: baseUrl() }); @@ -19,7 +25,7 @@ test.beforeEach(async ({ page }) => { await page.getByTestId("loadpoint0").getByLabel("Power").fill("6000"); await page.getByTestId("loadpoint0").getByText("C (charging)").click(); await page.getByTestId("loadpoint0").getByText("Enabled").check(); - await page.getByRole("button", { name: "Apply changes" }).click(); + await simulatorApply(page); }); test.describe("smart cost limit", async () => { diff --git a/tests/utils.ts b/tests/utils.ts index cd9701672..133cb9d1f 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -4,8 +4,7 @@ export async function enableExperimental(page: Page, inline = true): Promise { + await expect(page.getByTestId("topnavigation-button")).toBeVisible(); + await page.getByTestId("topnavigation-button").click(); + await expectTopNavigationOpened(page); +} + +export async function closeTopNavigation(page: Page): Promise { + await expectTopNavigationOpened(page); + await page.getByTestId("topnavigation-button").click(); + await expectTopNavigationClosed(page); +} + +export async function expectTopNavigationOpened(page: Page): Promise { + await expect(page.getByTestId("topnavigation-button")).toHaveAttribute("aria-expanded", "true"); + await expect(page.getByTestId("topnavigation-dropdown")).toBeVisible(); +} + +export async function expectTopNavigationClosed(page: Page): Promise { + await expect(page.getByTestId("topnavigation-button")).toHaveAttribute("aria-expanded", "false"); + await expect(page.getByTestId("topnavigation-dropdown")).not.toBeVisible(); +} + export async function expectModalVisible(modal: Locator): Promise { await expect(modal).toBeVisible(); await expect(modal).toHaveAttribute("aria-hidden", "false"); diff --git a/tests/vehicle-settings.spec.ts b/tests/vehicle-settings.spec.ts index 9b514c6a1..c88489303 100644 --- a/tests/vehicle-settings.spec.ts +++ b/tests/vehicle-settings.spec.ts @@ -1,6 +1,12 @@ import { test, expect } from "@playwright/test"; import { start, stop, restart, baseUrl } from "./evcc"; -import { startSimulator, stopSimulator, simulatorUrl, simulatorConfig } from "./simulator"; +import { + startSimulator, + stopSimulator, + simulatorUrl, + simulatorConfig, + simulatorApply, +} from "./simulator"; test.use({ baseURL: baseUrl() }); @@ -18,7 +24,7 @@ test.beforeEach(async ({ page }) => { await page.getByLabel("Grid Power").fill("500"); await page.getByTestId("vehicle0").getByLabel("SoC").fill("20"); await page.getByTestId("loadpoint0").getByText("B (connected)").click(); - await page.getByRole("button", { name: "Apply changes" }).click(); + await simulatorApply(page); }); test.afterEach(async () => {