evcc-io/tests/config-disable.spec.ts
andig 4465100a65
Config UI: allow disabling devices (#29455)
Co-authored-by: Michael Geers <michael@geers.tv>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: andig <184815+andig@users.noreply.github.com>
2026-08-11 13:24:40 +00:00

415 lines
14 KiB
TypeScript

import { test, expect, type Page, type Locator } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { startSimulator, stopSimulator, simulatorHost } from "./simulator";
import {
expectModalVisible,
expectModalHidden,
addDemoCharger,
newLoadpoint,
finishLoadpoint,
openMoreMenu,
editorClear,
editorPaste,
} from "./utils";
test.use({ baseURL: baseUrl() });
test.describe.configure({ mode: "parallel" });
test.afterEach(async () => {
await stop();
});
async function expectNoFatal(page: Page) {
await expect(page.getByTestId("fatal-error")).not.toBeVisible();
}
function autoAcceptDialogs(page: Page) {
page.on("dialog", (dialog) => dialog.accept());
}
function disabledBadge(card: Locator) {
return card.getByRole("button", { name: "Enable" });
}
async function createLoadpoint(page: Page, title: string) {
await newLoadpoint(page, title);
await addDemoCharger(page);
// charger save instant-creates the loadpoint
await finishLoadpoint(page);
}
async function toggleLoadpointDisable(page: Page, index: number, action: "Disable" | "Enable") {
const target = page.getByTestId("loadpoint").nth(index);
const lpModal = page.getByTestId("loadpoint-modal");
await target.getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal);
await lpModal.getByRole("button", { name: action }).click();
await expectModalHidden(lpModal);
}
test.describe("disable / enable", async () => {
test("loadpoint", async ({ page }) => {
autoAcceptDialogs(page);
await start();
await page.goto("/#/config");
// two loadpoints so disabling one keeps the site valid
await createLoadpoint(page, "Carport");
// reload between creations to reset modal state
await page.reload();
await createLoadpoint(page, "Garage");
const target = page.getByTestId("loadpoint").nth(0);
await toggleLoadpointDisable(page, 0, "Disable");
// card shows disabled state
await expect(disabledBadge(target)).toBeVisible();
// restart, no fatal
await restart();
await page.reload();
await expectNoFatal(page);
await expect(disabledBadge(target)).toBeVisible();
// re-enable by clicking the disabled card
await disabledBadge(target).click();
await expect(disabledBadge(target)).toHaveCount(0);
// restart, no fatal
await restart();
await page.reload();
await expectNoFatal(page);
await expect(disabledBadge(target)).toHaveCount(0);
});
test("grid meter", async ({ page }) => {
autoAcceptDialogs(page);
await start();
await page.goto("/#/config");
const meterModal = page.getByTestId("meter-modal");
// add grid meter
await page.getByRole("button", { name: "Add grid meter" }).click();
await expectModalVisible(meterModal);
await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
await meterModal.getByLabel("Power").fill("0");
await meterModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(meterModal);
const gridCard = page.getByTestId("grid");
// open modal, disable
await gridCard.getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal);
await expect(meterModal.getByRole("button", { name: "Disable" })).toBeVisible();
await meterModal.getByRole("button", { name: "Disable" }).click();
await expectModalHidden(meterModal);
// card shows disabled state
await expect(disabledBadge(gridCard)).toBeVisible();
// restart, no fatal
await restart();
await page.reload();
await expectNoFatal(page);
await expect(disabledBadge(gridCard)).toBeVisible();
// re-enable by clicking the disabled card
await disabledBadge(gridCard).click();
await expect(disabledBadge(gridCard)).toHaveCount(0);
// restart, no fatal
await restart();
await page.reload();
await expectNoFatal(page);
await expect(disabledBadge(gridCard)).toHaveCount(0);
});
test("pv meter", async ({ page }) => {
autoAcceptDialogs(page);
await start();
await page.goto("/#/config");
const meterModal = page.getByTestId("meter-modal");
// add pv meter
await page.getByRole("button", { name: "Add solar or battery" }).click();
await expectModalVisible(meterModal);
await meterModal.getByRole("button", { name: "Add solar meter" }).click();
await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
await meterModal.getByLabel("Title").fill("PV");
await meterModal.getByLabel("Power").fill("0");
await meterModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(meterModal);
const pvCard = page.getByTestId("pv");
// open modal, disable
await pvCard.getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal);
await expect(meterModal.getByRole("button", { name: "Disable" })).toBeVisible();
await meterModal.getByRole("button", { name: "Disable" }).click();
await expectModalHidden(meterModal);
// card shows disabled state
await expect(disabledBadge(pvCard)).toBeVisible();
// restart, no fatal
await restart();
await page.reload();
await expectNoFatal(page);
await expect(disabledBadge(pvCard)).toBeVisible();
// re-enable by clicking the disabled card
await disabledBadge(pvCard).click();
await expect(disabledBadge(pvCard)).toHaveCount(0);
// restart, no fatal
await restart();
await page.reload();
await expectNoFatal(page);
await expect(disabledBadge(pvCard)).toHaveCount(0);
});
test("grid tariff", async ({ page }) => {
autoAcceptDialogs(page);
await start();
await page.goto("/#/config");
const tariffModal = page.getByTestId("tariff-modal");
const gridCard = page.getByTestId("tariff-grid");
// add grid tariff
await page.getByRole("button", { name: "Add tariff" }).click();
await expectModalVisible(tariffModal);
await tariffModal.getByRole("button", { name: "Add grid import tariff" }).click();
await tariffModal.getByLabel("Provider").selectOption("Fixed Price");
await tariffModal.getByLabel("Price").fill("32.1");
await tariffModal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(tariffModal);
// open modal, disable
await gridCard.getByRole("button", { name: "edit" }).click();
await expectModalVisible(tariffModal);
await tariffModal.getByRole("button", { name: "Disable" }).click();
await expectModalHidden(tariffModal);
// card shows disabled state
await expect(disabledBadge(gridCard)).toBeVisible();
// restart, tariff not instantiated
await restart();
await page.reload();
await expectNoFatal(page);
await expect(disabledBadge(gridCard)).toBeVisible();
await expect(gridCard).not.toContainText("32.1");
// re-enable by clicking the disabled card
await disabledBadge(gridCard).click();
await expect(disabledBadge(gridCard)).toHaveCount(0);
// restart, tariff active again
await restart();
await page.reload();
await expectNoFatal(page);
await expect(disabledBadge(gridCard)).toHaveCount(0);
await expect(gridCard).toContainText(["Price", "32.1 ct"].join(""));
});
test("user-defined vehicle", async ({ page }) => {
autoAcceptDialogs(page);
await start();
await page.goto("/#/config");
// add user-defined vehicle
await page.getByTestId("add-vehicle").click();
const modal = page.getByTestId("vehicle-modal");
await expectModalVisible(modal);
await modal.getByLabel("Manufacturer").selectOption("User-defined device");
await page.waitForLoadState("networkidle");
const editor = modal.getByTestId("yaml-editor");
await editorClear(editor);
await editorPaste(
editor,
page,
`title: blue Honda
capacity: 12.3
soc:
source: const
value: 42`
);
await modal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(modal);
const vehicleCard = page.getByTestId("vehicle");
await expect(vehicleCard).toHaveCount(1);
// open modal, disable
await vehicleCard.getByRole("button", { name: "edit" }).click();
await expectModalVisible(modal);
await modal.getByRole("button", { name: "Disable" }).click();
await expectModalHidden(modal);
// card shows disabled state
await expect(disabledBadge(vehicleCard)).toBeVisible();
// restart, no fatal
await restart();
await page.reload();
await expectNoFatal(page);
await expect(disabledBadge(vehicleCard)).toBeVisible();
// re-enable by clicking the disabled card
await disabledBadge(vehicleCard).click();
await expect(disabledBadge(vehicleCard)).toHaveCount(0);
// restart, yaml config intact
await restart();
await page.reload();
await expectNoFatal(page);
await expect(vehicleCard).toContainText("blue Honda");
});
});
test.describe("disabled loadpoint behavior", async () => {
test("broken charger boots after disabling its loadpoint", async ({ page }) => {
autoAcceptDialogs(page);
await startSimulator();
await start();
await page.goto("/#/config");
// second loadpoint so the site stays valid
await createLoadpoint(page, "Carport");
await page.reload();
// loadpoint with shelly charger against simulator
await newLoadpoint(page, "Garage");
const lpModal = page.getByTestId("loadpoint-modal");
await lpModal.getByRole("button", { name: "Add charger" }).click();
const chargerModal = page.getByTestId("charger-modal");
await expectModalVisible(chargerModal);
await chargerModal.getByLabel("Manufacturer").selectOption("Shelly 1");
await chargerModal.getByLabel("IP address or hostname").fill(simulatorHost());
await chargerModal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(chargerModal);
await expectModalVisible(lpModal);
// charger save instant-creates the loadpoint
await finishLoadpoint(page);
await page.waitForLoadState("networkidle");
// break charger
await stopSimulator();
await restart();
await page.reload();
await expect(page.getByTestId("fatal-error")).toBeVisible();
// disable loadpoint with broken charger
await toggleLoadpointDisable(page, 1, "Disable");
await expect(disabledBadge(page.getByTestId("loadpoint").nth(1))).toBeVisible();
// clean boot, charger no longer instantiated
await restart();
await page.reload();
await expectNoFatal(page);
await expect(disabledBadge(page.getByTestId("loadpoint").nth(1))).toBeVisible();
// re-enable, fatal returns
await disabledBadge(page.getByTestId("loadpoint").nth(1)).click();
await expect(disabledBadge(page.getByTestId("loadpoint").nth(1))).toHaveCount(0);
await restart();
await page.reload();
await expect(page.getByTestId("fatal-error")).toBeVisible();
});
test("disabling a loadpoint keeps api indexes stable", async ({ page }) => {
autoAcceptDialogs(page);
await start();
await page.goto("/#/config");
await createLoadpoint(page, "Carport");
await page.reload();
await createLoadpoint(page, "Garage");
await page.reload();
await createLoadpoint(page, "Süd");
// disable middle loadpoint
await toggleLoadpointDisable(page, 1, "Disable");
await restart();
// main ui hides disabled loadpoint
await page.goto("/");
await expect(page.getByTestId("loadpoint")).toHaveCount(2);
await expect(page.getByRole("heading", { name: "Garage" })).not.toBeVisible();
await expect(page.getByTestId("loadpoint").nth(0)).toContainText("Carport");
await expect(page.getByTestId("loadpoint").nth(1)).toContainText("Süd");
// state keeps disabled loadpoint at its position
const state = await (await page.request.get("/api/state")).json();
expect(state.loadpoints).toHaveLength(3);
expect(state.loadpoints[1].disabled).toBe(true);
expect(state.loadpoints[1].title).toBe("Garage");
// api index of third loadpoint does not shift
const res = await page.request.post("/api/loadpoints/3/mode/now");
expect(res.status()).toBe(200);
const updated = await (await page.request.get("/api/state")).json();
expect(updated.loadpoints[2].mode).toBe("now");
expect(updated.loadpoints[0].mode).not.toBe("now");
// disabled loadpoint has no api routes
const disabledRes = await page.request.post("/api/loadpoints/2/mode/now");
expect(disabledRes.status()).toBe(404);
// ui settings show disabled loadpoint as inactive row
const menu = await openMoreMenu(page);
await menu.getByRole("button", { name: "User Interface" }).click();
const modal = page.getByTestId("global-settings-modal");
await expectModalVisible(modal);
const garageRow = modal.getByRole("listitem", { name: "Draggable: Garage" });
await expect(garageRow).toContainText("disabled");
await expect(modal.getByRole("switch", { name: "Hide Garage" })).toHaveCount(0);
await expect(modal.getByRole("switch", { name: "Hide Carport" })).toBeEnabled();
// last-visible guard ignores disabled loadpoint
await modal.getByRole("switch", { name: "Hide Carport" }).click();
await expect(modal.getByRole("switch", { name: "Hide Süd" })).toBeDisabled();
});
test("editing a disabled loadpoint", async ({ page }) => {
autoAcceptDialogs(page);
await start();
await page.goto("/#/config");
await createLoadpoint(page, "Carport");
await page.reload();
await createLoadpoint(page, "Garage");
await toggleLoadpointDisable(page, 1, "Disable");
const target = page.getByTestId("loadpoint").nth(1);
const lpModal = page.getByTestId("loadpoint-modal");
// update title while disabled
await target.getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal);
await lpModal.getByLabel("Title").fill("Garage 2");
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
await expect(target).toContainText("Garage 2");
// survives restart, still disabled
await restart();
await page.reload();
await expect(target).toContainText("Garage 2");
await expect(disabledBadge(target)).toBeVisible();
// re-enable, title applies to live loadpoint
await disabledBadge(target).click();
await expect(disabledBadge(target)).toHaveCount(0);
await restart();
await page.goto("/");
await expect(page.getByRole("heading", { name: "Garage 2" })).toBeVisible();
});
});