From bcb29b3c38449d934b5b793e70cdbb1237b326d7 Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 10 Jul 2024 10:22:01 +0200 Subject: [PATCH] Add error class for config file issues (#14495) --- assets/js/auth.js | 18 ++++ assets/js/components/TopNavigation.vue | 4 +- assets/js/router.js | 5 +- assets/js/store.js | 4 + assets/js/utils/fatal.js | 21 ++++ assets/js/views/App.vue | 14 +-- cmd/class_enumer.go | 140 +++++++++++++------------ cmd/error.go | 1 + cmd/root.go | 2 +- cmd/setup.go | 26 +++-- playwright.config.js | 2 +- tests/auth.spec.js | 52 ++++----- tests/config-battery.spec.js | 1 + tests/config-grid.spec.js | 1 + tests/config-mqtt.spec.js | 1 + tests/config-tariffs.spec.js | 1 + tests/config-vehicles.spec.js | 1 + tests/config.spec.js | 1 + tests/evcc.js | 44 +++++--- tests/fatal-db.evcc.yaml | 6 ++ tests/fatal-syntax.evcc.yaml | 2 + tests/fatal.spec.js | 23 ++++ tests/logs.spec.js | 1 + tests/modals.spec.js | 1 + 24 files changed, 235 insertions(+), 137 deletions(-) create mode 100644 assets/js/utils/fatal.js create mode 100755 tests/fatal-db.evcc.yaml create mode 100755 tests/fatal-syntax.evcc.yaml create mode 100644 tests/fatal.spec.js diff --git a/assets/js/auth.js b/assets/js/auth.js index f2f94b56c..c2aaf8fc9 100644 --- a/assets/js/auth.js +++ b/assets/js/auth.js @@ -1,6 +1,8 @@ import { reactive, watch } from "vue"; import api from "./api"; +import store from "./store"; import Modal from "bootstrap/js/dist/modal"; +import { isSystemError } from "./utils/fatal"; const auth = reactive({ configured: true, @@ -9,6 +11,11 @@ const auth = reactive({ }); export async function updateAuthStatus() { + if (store.state.offline || isSystemError(store.state.fatal)) { + // system not ready, skip auth check + return; + } + try { const res = await api.get("/auth/status", { validateStatus: (code) => [200, 501, 500].includes(code), @@ -72,4 +79,15 @@ watch( } ); +let timeoutId = null; +function debounedUpdateAuthStatus() { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + updateAuthStatus(); + }, 500); +} + +watch(() => store.state.offline, debounedUpdateAuthStatus); +watch(() => store.state.fatal, debounedUpdateAuthStatus); + export default auth; diff --git a/assets/js/components/TopNavigation.vue b/assets/js/components/TopNavigation.vue index 2021a91e6..7a1c6043c 100644 --- a/assets/js/components/TopNavigation.vue +++ b/assets/js/components/TopNavigation.vue @@ -124,6 +124,7 @@ import collector from "../mixins/collector"; import { logout, isLoggedIn, openLoginModal } from "../auth"; import baseAPI from "../baseapi"; import { isApp, sendToApp } from "../utils/native"; +import { isUserConfigError } from "../utils/fatal"; export default { name: "TopNavigation", @@ -168,7 +169,8 @@ export default { return this.logoutCount > 0; }, showBadge() { - return this.loginRequired || this.sponsor.expiresSoon || this.fatal?.error; + const userConfigError = isUserConfigError(this.fatal); + return this.loginRequired || this.sponsor.expiresSoon || userConfigError; }, badgeClass() { if (this.fatal?.error) { diff --git a/assets/js/router.js b/assets/js/router.js index 5b338d7b8..b0b993f66 100644 --- a/assets/js/router.js +++ b/assets/js/router.js @@ -2,7 +2,7 @@ import { createRouter, createWebHashHistory } from "vue-router"; import Modal from "bootstrap/js/dist/modal"; import Main from "./views/Main.vue"; import { ensureCurrentLocaleMessages } from "./i18n"; -import { openLoginModal, statusUnknown, updateAuthStatus, isLoggedIn } from "./auth"; +import { openLoginModal, statusUnknown, updateAuthStatus, isLoggedIn, isConfigured } from "./auth"; function hideAllModals() { [...document.querySelectorAll(".modal.show")].forEach((modal) => { @@ -18,6 +18,9 @@ function hideAllModals() { async function ensureAuth(to) { await updateAuthStatus(); + if (!isConfigured()) { + return false; + } if (!isLoggedIn() && !statusUnknown()) { openLoginModal(to.path); return false; diff --git a/assets/js/store.js b/assets/js/store.js index 1841ec006..049422812 100644 --- a/assets/js/store.js +++ b/assets/js/store.js @@ -19,11 +19,15 @@ function setProperty(obj, props, value) { } const state = reactive({ + offline: false, loadpoints: [], // ensure array type }); const store = { state, + offline: function (value) { + state.offline = value; + }, update: function (msg) { Object.keys(msg).forEach(function (k) { if (k === "log") { diff --git a/assets/js/utils/fatal.js b/assets/js/utils/fatal.js new file mode 100644 index 000000000..78682d746 --- /dev/null +++ b/assets/js/utils/fatal.js @@ -0,0 +1,21 @@ +const FATALS = ["configfile", "database"]; + +function isError(fatal) { + return !!fatal?.error; +} + +export function isUserConfigError(fatal) { + if (!isError(fatal)) { + return false; + } + + const errorClass = fatal?.class; + if (FATALS.includes(errorClass)) { + return false; + } + return true; +} + +export function isSystemError(fatal) { + return isError(fatal) && !isUserConfigError(fatal); +} diff --git a/assets/js/views/App.vue b/assets/js/views/App.vue index 12c187f4d..74cc6d6c0 100644 --- a/assets/js/views/App.vue +++ b/assets/js/views/App.vue @@ -20,7 +20,6 @@ import PasswordModal from "../components/PasswordModal.vue"; import LoginModal from "../components/LoginModal.vue"; import HelpModal from "../components/HelpModal.vue"; import collector from "../mixins/collector"; -import { updateAuthStatus } from "../auth"; // assume offline if not data received for 5 minutes let lastDataReceived = new Date(); @@ -61,9 +60,9 @@ export default { this.reload(); } }, - offline: function (now, prev) { - updateAuthStatus(); - if (now && !prev) { + offline: function (offline) { + store.offline(offline); + if (offline) { this.reconnect(); } }, @@ -88,7 +87,6 @@ export default { mounted: function () { this.connect(); document.addEventListener("visibilitychange", this.pageVisibilityChanged, false); - updateAuthStatus(); }, unmounted: function () { this.disconnect(); @@ -102,7 +100,6 @@ export default { this.disconnect(); } else { this.connect(); - updateAuthStatus(); } }, reconnect: function () { @@ -113,7 +110,6 @@ export default { }, 2500); }, disconnect: function () { - console.log("websocket disconnecting"); if (this.ws) { this.ws.onerror = null; this.ws.onopen = null; @@ -150,7 +146,7 @@ export default { this.ws = new WebSocket(uri); this.ws.onerror = () => { - console.error({ message: "Websocket error. Trying to reconnect." }); + console.log({ message: "Websocket error. Trying to reconnect." }); this.ws.close(); }; this.ws.onopen = () => { @@ -158,8 +154,8 @@ export default { window.app.setOnline(); }; this.ws.onclose = () => { - console.log("websocket disconnected"); window.app.setOffline(); + this.reconnect(); }; this.ws.onmessage = (evt) => { try { diff --git a/cmd/class_enumer.go b/cmd/class_enumer.go index 91dc339b5..68cb935c0 100644 --- a/cmd/class_enumer.go +++ b/cmd/class_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _ClassName = "meterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsinfluxmessengersponsorship" +const _ClassName = "configfilemeterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsinfluxmessengersponsorship" -var _ClassIndex = [...]uint8{0, 5, 12, 19, 25, 32, 36, 40, 48, 59, 64, 74, 76, 80, 86, 95, 106} +var _ClassIndex = [...]uint8{0, 10, 15, 22, 29, 35, 42, 46, 50, 58, 69, 74, 84, 86, 90, 96, 105, 116} -const _ClassLowerName = "meterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsinfluxmessengersponsorship" +const _ClassLowerName = "configfilemeterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsinfluxmessengersponsorship" func (i Class) String() string { i -= 1 @@ -25,78 +25,82 @@ func (i Class) String() string { // Re-run the stringer command to generate them again. func _ClassNoOp() { var x [1]struct{} - _ = x[ClassMeter-(1)] - _ = x[ClassCharger-(2)] - _ = x[ClassVehicle-(3)] - _ = x[ClassTariff-(4)] - _ = x[ClassCircuit-(5)] - _ = x[ClassSite-(6)] - _ = x[ClassMqtt-(7)] - _ = x[ClassDatabase-(8)] - _ = x[ClassModbusProxy-(9)] - _ = x[ClassEEBus-(10)] - _ = x[ClassJavascript-(11)] - _ = x[ClassGo-(12)] - _ = x[ClassHEMS-(13)] - _ = x[ClassInflux-(14)] - _ = x[ClassMessenger-(15)] - _ = x[ClassSponsorship-(16)] + _ = x[ClassConfigFile-(1)] + _ = x[ClassMeter-(2)] + _ = x[ClassCharger-(3)] + _ = x[ClassVehicle-(4)] + _ = x[ClassTariff-(5)] + _ = x[ClassCircuit-(6)] + _ = x[ClassSite-(7)] + _ = x[ClassMqtt-(8)] + _ = x[ClassDatabase-(9)] + _ = x[ClassModbusProxy-(10)] + _ = x[ClassEEBus-(11)] + _ = x[ClassJavascript-(12)] + _ = x[ClassGo-(13)] + _ = x[ClassHEMS-(14)] + _ = x[ClassInflux-(15)] + _ = x[ClassMessenger-(16)] + _ = x[ClassSponsorship-(17)] } -var _ClassValues = []Class{ClassMeter, ClassCharger, ClassVehicle, ClassTariff, ClassCircuit, ClassSite, ClassMqtt, ClassDatabase, ClassModbusProxy, ClassEEBus, ClassJavascript, ClassGo, ClassHEMS, ClassInflux, ClassMessenger, ClassSponsorship} +var _ClassValues = []Class{ClassConfigFile, ClassMeter, ClassCharger, ClassVehicle, ClassTariff, ClassCircuit, ClassSite, ClassMqtt, ClassDatabase, ClassModbusProxy, ClassEEBus, ClassJavascript, ClassGo, ClassHEMS, ClassInflux, ClassMessenger, ClassSponsorship} var _ClassNameToValueMap = map[string]Class{ - _ClassName[0:5]: ClassMeter, - _ClassLowerName[0:5]: ClassMeter, - _ClassName[5:12]: ClassCharger, - _ClassLowerName[5:12]: ClassCharger, - _ClassName[12:19]: ClassVehicle, - _ClassLowerName[12:19]: ClassVehicle, - _ClassName[19:25]: ClassTariff, - _ClassLowerName[19:25]: ClassTariff, - _ClassName[25:32]: ClassCircuit, - _ClassLowerName[25:32]: ClassCircuit, - _ClassName[32:36]: ClassSite, - _ClassLowerName[32:36]: ClassSite, - _ClassName[36:40]: ClassMqtt, - _ClassLowerName[36:40]: ClassMqtt, - _ClassName[40:48]: ClassDatabase, - _ClassLowerName[40:48]: ClassDatabase, - _ClassName[48:59]: ClassModbusProxy, - _ClassLowerName[48:59]: ClassModbusProxy, - _ClassName[59:64]: ClassEEBus, - _ClassLowerName[59:64]: ClassEEBus, - _ClassName[64:74]: ClassJavascript, - _ClassLowerName[64:74]: ClassJavascript, - _ClassName[74:76]: ClassGo, - _ClassLowerName[74:76]: ClassGo, - _ClassName[76:80]: ClassHEMS, - _ClassLowerName[76:80]: ClassHEMS, - _ClassName[80:86]: ClassInflux, - _ClassLowerName[80:86]: ClassInflux, - _ClassName[86:95]: ClassMessenger, - _ClassLowerName[86:95]: ClassMessenger, - _ClassName[95:106]: ClassSponsorship, - _ClassLowerName[95:106]: ClassSponsorship, + _ClassName[0:10]: ClassConfigFile, + _ClassLowerName[0:10]: ClassConfigFile, + _ClassName[10:15]: ClassMeter, + _ClassLowerName[10:15]: ClassMeter, + _ClassName[15:22]: ClassCharger, + _ClassLowerName[15:22]: ClassCharger, + _ClassName[22:29]: ClassVehicle, + _ClassLowerName[22:29]: ClassVehicle, + _ClassName[29:35]: ClassTariff, + _ClassLowerName[29:35]: ClassTariff, + _ClassName[35:42]: ClassCircuit, + _ClassLowerName[35:42]: ClassCircuit, + _ClassName[42:46]: ClassSite, + _ClassLowerName[42:46]: ClassSite, + _ClassName[46:50]: ClassMqtt, + _ClassLowerName[46:50]: ClassMqtt, + _ClassName[50:58]: ClassDatabase, + _ClassLowerName[50:58]: ClassDatabase, + _ClassName[58:69]: ClassModbusProxy, + _ClassLowerName[58:69]: ClassModbusProxy, + _ClassName[69:74]: ClassEEBus, + _ClassLowerName[69:74]: ClassEEBus, + _ClassName[74:84]: ClassJavascript, + _ClassLowerName[74:84]: ClassJavascript, + _ClassName[84:86]: ClassGo, + _ClassLowerName[84:86]: ClassGo, + _ClassName[86:90]: ClassHEMS, + _ClassLowerName[86:90]: ClassHEMS, + _ClassName[90:96]: ClassInflux, + _ClassLowerName[90:96]: ClassInflux, + _ClassName[96:105]: ClassMessenger, + _ClassLowerName[96:105]: ClassMessenger, + _ClassName[105:116]: ClassSponsorship, + _ClassLowerName[105:116]: ClassSponsorship, } var _ClassNames = []string{ - _ClassName[0:5], - _ClassName[5:12], - _ClassName[12:19], - _ClassName[19:25], - _ClassName[25:32], - _ClassName[32:36], - _ClassName[36:40], - _ClassName[40:48], - _ClassName[48:59], - _ClassName[59:64], - _ClassName[64:74], - _ClassName[74:76], - _ClassName[76:80], - _ClassName[80:86], - _ClassName[86:95], - _ClassName[95:106], + _ClassName[0:10], + _ClassName[10:15], + _ClassName[15:22], + _ClassName[22:29], + _ClassName[29:35], + _ClassName[35:42], + _ClassName[42:46], + _ClassName[46:50], + _ClassName[50:58], + _ClassName[58:69], + _ClassName[69:74], + _ClassName[74:84], + _ClassName[84:86], + _ClassName[86:90], + _ClassName[90:96], + _ClassName[96:105], + _ClassName[105:116], } // ClassString retrieves an enum value from the enum constants string name. diff --git a/cmd/error.go b/cmd/error.go index 7744080c8..3d8c78e8a 100644 --- a/cmd/error.go +++ b/cmd/error.go @@ -10,6 +10,7 @@ type Class int //go:generate enumer -type Class -trimprefix Class -transform=lower -text const ( _ Class = iota + ClassConfigFile ClassMeter ClassCharger ClassVehicle diff --git a/cmd/root.go b/cmd/root.go index ecdd9aa19..9f7f167ba 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -119,7 +119,7 @@ func runRoot(cmd *cobra.Command, args []string) { log.FATAL.Fatal(err) } } else { - err = cfgErr + err = wrapErrorWithClass(ClassConfigFile, cfgErr) } // setup environment diff --git a/cmd/setup.go b/cmd/setup.go index 328fc6034..22084d9bd 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -486,6 +486,17 @@ func configureEnvironment(cmd *cobra.Command, conf *globalconfig.All) (err error request.LogHeaders = true } + // setup persistence + if err == nil { + err = wrapErrorWithClass(ClassDatabase, configureDatabase(conf.Database)) + } + + // setup translations + if err == nil { + // TODO decide wrapping + err = locale.Init() + } + // setup machine id if conf.Plant != "" { // TODO decide wrapping @@ -497,17 +508,6 @@ func configureEnvironment(cmd *cobra.Command, conf *globalconfig.All) (err error err = wrapErrorWithClass(ClassSponsorship, configureSponsorship(conf.SponsorToken)) } - // setup translations - if err == nil { - // TODO decide wrapping - err = locale.Init() - } - - // setup persistence - if err == nil && conf.Database.Dsn != "" { - err = wrapErrorWithClass(ClassDatabase, configureDatabase(conf.Database)) - } - // setup mqtt client listener if err == nil { err = wrapErrorWithClass(ClassMqtt, configureMqtt(&conf.Mqtt)) @@ -539,6 +539,10 @@ func configureEnvironment(cmd *cobra.Command, conf *globalconfig.All) (err error // configureDatabase configures session database func configureDatabase(conf globalconfig.DB) error { + if conf.Dsn == "" { + return errors.New("database dsn not configured") + } + if err := db.NewInstance(conf.Type, conf.Dsn); err != nil { return err } diff --git a/playwright.config.js b/playwright.config.js index 6ee55f81e..c4c560110 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -8,7 +8,7 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, timeout: 15000, // 15s (default 30s) - workers: process.env.CI ? 3 : 8, + workers: process.env.CI ? 3 : 4, reporter: "html", use: { baseURL: "http://127.0.0.1:7070", diff --git a/tests/auth.spec.js b/tests/auth.spec.js index f11b621f5..04c1665d2 100644 --- a/tests/auth.spec.js +++ b/tests/auth.spec.js @@ -3,16 +3,12 @@ import { start, stop, baseUrl } from "./evcc"; test.use({ baseURL: baseUrl() }); -test.beforeEach(async ({ page }) => { - await start("basics.evcc.yaml"); - await page.goto("/"); -}); - -test.afterEach(async () => { - await stop(); -}); +const BASIC = "basics.evcc.yaml"; test("set initial password", async ({ page }) => { + await start(BASIC); + await page.goto("/"); + const modal = page.getByTestId("password-modal"); await expect(modal).toBeVisible(); @@ -33,14 +29,13 @@ test("set initial password", async ({ page }) => { await modal.getByLabel("Repeat password").fill("secret"); await modal.getByRole("button", { name: "Create Password" }).click(); await expect(modal).not.toBeVisible(); + + await stop(); }); test("login", async ({ page }) => { - // set initial password - const modal = page.getByTestId("password-modal"); - await modal.getByLabel("New password").fill("secret"); - await modal.getByLabel("Repeat password").fill("secret"); - await modal.getByRole("button", { name: "Create Password" }).click(); + await start(BASIC, "password.sql"); + await page.goto("/"); // go to config await page.getByTestId("topnavigation-button").click(); @@ -61,14 +56,13 @@ test("login", async ({ page }) => { await login.getByRole("button", { name: "Login" }).click(); await expect(login).not.toBeVisible(); await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible(); + + await stop(); }); test("http iframe hint", async ({ page }) => { - // set initial password - const modal = page.getByTestId("password-modal"); - await modal.getByLabel("New password").fill("secret"); - await modal.getByLabel("Repeat password").fill("secret"); - await modal.getByRole("button", { name: "Create Password" }).click(); + await start(BASIC, "password.sql"); + await page.goto("/"); // go to config await page.getByTestId("topnavigation-button").click(); @@ -90,26 +84,27 @@ test("http iframe hint", async ({ page }) => { // iframe hint visible (login-iframe-hint) await expect(login.getByTestId("login-iframe-hint")).toBeVisible(); + + await stop(); }); test("update password", async ({ page }) => { + const instance = await start(BASIC, "password.sql"); + await page.goto("/"); + const oldPassword = "secret"; const newPassword = "newsecret"; - // set initial password - const modal = page.getByTestId("password-modal"); - await modal.getByLabel("New password").fill(oldPassword); - await modal.getByLabel("Repeat password").fill(oldPassword); - await modal.getByRole("button", { name: "Create Password" }).click(); - // login modal page.goto("/#/config"); const loginOld = page.getByTestId("login-modal"); await loginOld.getByLabel("Password").fill(oldPassword); await loginOld.getByRole("button", { name: "Login" }).click(); + await expect(loginOld).not.toBeVisible(); // update password await page.getByTestId("generalconfig-password").getByRole("button", { name: "edit" }).click(); + const modal = page.getByTestId("password-modal"); await expect(modal.getByRole("heading", { name: "Update Administrator Password" })).toBeVisible(); await modal.getByLabel("Current password").fill(oldPassword); await modal.getByLabel("New password").fill(newPassword); @@ -133,11 +128,6 @@ test("update password", async ({ page }) => { await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible(); await expect(loginNew).not.toBeVisible(); - // revert password - await page.getByTestId("generalconfig-password").getByRole("button", { name: "edit" }).click(); - await modal.getByLabel("Current password").fill(newPassword); - await modal.getByLabel("New password").fill(oldPassword); - await modal.getByLabel("Repeat password").fill(oldPassword); - await modal.getByRole("button", { name: "Update Password" }).click(); - await expect(page.getByTestId("password-modal")).not.toBeVisible(); + // hard stop, since password is updated + await stop(instance); }); diff --git a/tests/config-battery.spec.js b/tests/config-battery.spec.js index 54c6492cd..2756afb35 100644 --- a/tests/config-battery.spec.js +++ b/tests/config-battery.spec.js @@ -18,6 +18,7 @@ test.afterAll(async () => { async function login(page) { await page.locator("#loginPassword").fill("secret"); await page.getByRole("button", { name: "Login" }).click(); + await expect(page.locator("#loginPassword")).not.toBeVisible(); } async function enableExperimental(page) { diff --git a/tests/config-grid.spec.js b/tests/config-grid.spec.js index d4d97b273..d4bdbd84e 100644 --- a/tests/config-grid.spec.js +++ b/tests/config-grid.spec.js @@ -18,6 +18,7 @@ test.afterAll(async () => { async function login(page) { await page.locator("#loginPassword").fill("secret"); await page.getByRole("button", { name: "Login" }).click(); + await expect(page.locator("#loginPassword")).not.toBeVisible(); } async function enableExperimental(page) { diff --git a/tests/config-mqtt.spec.js b/tests/config-mqtt.spec.js index b75755438..505044c6b 100644 --- a/tests/config-mqtt.spec.js +++ b/tests/config-mqtt.spec.js @@ -19,6 +19,7 @@ test.afterEach(async () => { async function login(page) { await page.locator("#loginPassword").fill("secret"); await page.getByRole("button", { name: "Login" }).click(); + await expect(page.locator("#loginPassword")).not.toBeVisible(); } async function enableExperimental(page) { diff --git a/tests/config-tariffs.spec.js b/tests/config-tariffs.spec.js index 9583fe2e1..fa55e0958 100644 --- a/tests/config-tariffs.spec.js +++ b/tests/config-tariffs.spec.js @@ -15,6 +15,7 @@ const SELECT_ALL = "ControlOrMeta+KeyA"; async function login(page) { await page.locator("#loginPassword").fill("secret"); await page.getByRole("button", { name: "Login" }).click(); + await expect(page.locator("#loginPassword")).not.toBeVisible(); } async function enableExperimental(page) { diff --git a/tests/config-vehicles.spec.js b/tests/config-vehicles.spec.js index 02133c8fd..fa9b5ec36 100644 --- a/tests/config-vehicles.spec.js +++ b/tests/config-vehicles.spec.js @@ -16,6 +16,7 @@ test.afterAll(async () => { async function login(page) { await page.locator("#loginPassword").fill("secret"); await page.getByRole("button", { name: "Login" }).click(); + await expect(page.locator("#loginPassword")).not.toBeVisible(); } async function enableExperimental(page) { diff --git a/tests/config.spec.js b/tests/config.spec.js index 4c7ed54a3..fb2a72134 100644 --- a/tests/config.spec.js +++ b/tests/config.spec.js @@ -15,6 +15,7 @@ test.afterAll(async () => { async function login(page) { await page.locator("#loginPassword").fill("secret"); await page.getByRole("button", { name: "Login" }).click(); + await expect(page.locator("#loginPassword")).not.toBeVisible(); } async function enableExperimental(page) { diff --git a/tests/evcc.js b/tests/evcc.js index 81309e745..d36e53063 100644 --- a/tests/evcc.js +++ b/tests/evcc.js @@ -7,17 +7,21 @@ import path from "path"; const BINARY = "./evcc"; -function port() { +function workerPort() { const index = process.env.TEST_WORKER_INDEX * 1; return 11000 + index; } +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export function baseUrl() { - return `http://localhost:${port()}`; + return `http://localhost:${workerPort()}`; } function dbPath() { - const file = `evcc-${port()}.db`; + const file = `evcc-${workerPort()}.db`; return path.join(os.tmpdir(), file); } @@ -26,11 +30,11 @@ export async function start(config, sqlDumps) { if (sqlDumps) { await _restoreDatabase(sqlDumps); } - await _start(config); + return await _start(config); } -export async function stop() { - await _stop(); +export async function stop(instance) { + await _stop(instance); await _clean(); } @@ -58,27 +62,39 @@ async function _restoreDatabase(sqlDumps) { async function _start(config) { const configFile = config.includes("/") ? config : `tests/${config}`; - console.log("starting evcc", { config }); + const port = workerPort(); + console.log(`wait until port ${port} is available`); + await waitOn({ resources: [`tcp:localhost:${port}`], reverse: true }); + console.log("starting evcc", { config, port }); const instance = exec( - `EVCC_NETWORK_PORT=${port()} EVCC_DATABASE_DSN=${dbPath()} ${BINARY} --config ${configFile}` + `EVCC_NETWORK_PORT=${port} EVCC_DATABASE_DSN=${dbPath()} ${BINARY} --config ${configFile}` ); instance.stdout.pipe(process.stdout); instance.stderr.pipe(process.stderr); instance.on("exit", (code) => { - console.log("evcc terminated", code); + console.log("evcc terminated", { code, port, config }); }); await waitOn({ resources: [baseUrl()] }); + return instance; } -async function _stop() { - console.log("shutting down evcc"); +async function _stop(instance) { + if (instance) { + console.log("shutting down evcc hard"); + // hard kill, only use of normal shutdown doesn't work + instance.kill("SIGKILL"); + await sleep(300); + return; + } + const port = workerPort(); + console.log("shutting down evcc", { port }); const res = await axios.post(`${baseUrl()}/api/auth/login`, { password: "secret" }); console.log(res.status, res.statusText); const cookie = res.headers["set-cookie"]; await axios.post(`${baseUrl()}/api/system/shutdown`, {}, { headers: { cookie } }); - console.log("wait until network port is closed"); - await waitOn({ resources: [`tcp:localhost:${port()}`], reverse: true }); - console.log("evcc is down"); + console.log(`wait until port ${port} is closed`); + await waitOn({ resources: [`tcp:localhost:${port}`], reverse: true }); + console.log("evcc is down", { port }); } async function _clean() { diff --git a/tests/fatal-db.evcc.yaml b/tests/fatal-db.evcc.yaml new file mode 100755 index 000000000..f86240d93 --- /dev/null +++ b/tests/fatal-db.evcc.yaml @@ -0,0 +1,6 @@ +site: + title: Hello World + +database: + type: sqliteInvalid + dsn: /path/to/db diff --git a/tests/fatal-syntax.evcc.yaml b/tests/fatal-syntax.evcc.yaml new file mode 100755 index 000000000..09bb239e2 --- /dev/null +++ b/tests/fatal-syntax.evcc.yaml @@ -0,0 +1,2 @@ +s!ite: + title: Hello World diff --git a/tests/fatal.spec.js b/tests/fatal.spec.js new file mode 100644 index 000000000..ffd17be41 --- /dev/null +++ b/tests/fatal.spec.js @@ -0,0 +1,23 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, baseUrl } from "./evcc"; + +test.use({ baseURL: baseUrl() }); + +test.describe("fatal", async () => { + test("evcc yaml error", async ({ page }) => { + const instance = await start("fatal-syntax.evcc.yaml"); + await page.goto("/"); + await expect(page.getByTestId("bottom-banner")).toBeVisible(); + await expect(page.getByTestId("bottom-banner")).toContainText("failed parsing config file"); + await expect(page.getByTestId("generalconfig-password")).not.toBeVisible(); + await stop(instance); + }); + test("database error", async ({ page }) => { + const instance = await start("fatal-db.evcc.yaml"); + await page.goto("/"); + await expect(page.getByTestId("bottom-banner")).toBeVisible(); + await expect(page.getByTestId("bottom-banner")).toContainText("invalid database"); + await expect(page.getByTestId("generalconfig-password")).not.toBeVisible(); + await stop(instance); + }); +}); diff --git a/tests/logs.spec.js b/tests/logs.spec.js index 7df2a289e..6ac4e4412 100644 --- a/tests/logs.spec.js +++ b/tests/logs.spec.js @@ -13,6 +13,7 @@ test.afterAll(async () => { async function login(page) { await page.locator("#loginPassword").fill("secret"); await page.getByRole("button", { name: "Login" }).click(); + await expect(page.locator("#loginPassword")).not.toBeVisible(); } test.describe("opening logs", async () => { diff --git a/tests/modals.spec.js b/tests/modals.spec.js index 7d6fe553a..66249f84e 100644 --- a/tests/modals.spec.js +++ b/tests/modals.spec.js @@ -11,6 +11,7 @@ test.use({ baseURL: baseUrl() }); async function login(page) { await page.locator("#loginPassword").fill("secret"); await page.getByRole("button", { name: "Login" }).click(); + await expect(page.locator("#loginPassword")).not.toBeVisible(); } test.describe("Basics", async () => {