Add error class for config file issues (#14495)

This commit is contained in:
andig 2024-07-10 10:22:01 +02:00 • committed by GitHub
parent 71ac9e270a
commit bcb29b3c38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 235 additions and 137 deletions

View file

@ -1,6 +1,8 @@
import { reactive, watch } from "vue"; import { reactive, watch } from "vue";
import api from "./api"; import api from "./api";
import store from "./store";
import Modal from "bootstrap/js/dist/modal"; import Modal from "bootstrap/js/dist/modal";
import { isSystemError } from "./utils/fatal";
const auth = reactive({ const auth = reactive({
configured: true, configured: true,
@ -9,6 +11,11 @@ const auth = reactive({
}); });
export async function updateAuthStatus() { export async function updateAuthStatus() {
if (store.state.offline || isSystemError(store.state.fatal)) {
// system not ready, skip auth check
return;
}
try { try {
const res = await api.get("/auth/status", { const res = await api.get("/auth/status", {
validateStatus: (code) => [200, 501, 500].includes(code), 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; export default auth;

View file

@ -124,6 +124,7 @@ import collector from "../mixins/collector";
import { logout, isLoggedIn, openLoginModal } from "../auth"; import { logout, isLoggedIn, openLoginModal } from "../auth";
import baseAPI from "../baseapi"; import baseAPI from "../baseapi";
import { isApp, sendToApp } from "../utils/native"; import { isApp, sendToApp } from "../utils/native";
import { isUserConfigError } from "../utils/fatal";
export default { export default {
name: "TopNavigation", name: "TopNavigation",
@ -168,7 +169,8 @@ export default {
return this.logoutCount > 0; return this.logoutCount > 0;
}, },
showBadge() { showBadge() {
return this.loginRequired || this.sponsor.expiresSoon || this.fatal?.error; const userConfigError = isUserConfigError(this.fatal);
return this.loginRequired || this.sponsor.expiresSoon || userConfigError;
}, },
badgeClass() { badgeClass() {
if (this.fatal?.error) { if (this.fatal?.error) {

View file

@ -2,7 +2,7 @@ import { createRouter, createWebHashHistory } from "vue-router";
import Modal from "bootstrap/js/dist/modal"; import Modal from "bootstrap/js/dist/modal";
import Main from "./views/Main.vue"; import Main from "./views/Main.vue";
import { ensureCurrentLocaleMessages } from "./i18n"; import { ensureCurrentLocaleMessages } from "./i18n";
import { openLoginModal, statusUnknown, updateAuthStatus, isLoggedIn } from "./auth"; import { openLoginModal, statusUnknown, updateAuthStatus, isLoggedIn, isConfigured } from "./auth";
function hideAllModals() { function hideAllModals() {
[...document.querySelectorAll(".modal.show")].forEach((modal) => { [...document.querySelectorAll(".modal.show")].forEach((modal) => {
@ -18,6 +18,9 @@ function hideAllModals() {
async function ensureAuth(to) { async function ensureAuth(to) {
await updateAuthStatus(); await updateAuthStatus();
if (!isConfigured()) {
return false;
}
if (!isLoggedIn() && !statusUnknown()) { if (!isLoggedIn() && !statusUnknown()) {
openLoginModal(to.path); openLoginModal(to.path);
return false; return false;

View file

@ -19,11 +19,15 @@ function setProperty(obj, props, value) {
} }
const state = reactive({ const state = reactive({
offline: false,
loadpoints: [], // ensure array type loadpoints: [], // ensure array type
}); });
const store = { const store = {
state, state,
offline: function (value) {
state.offline = value;
},
update: function (msg) { update: function (msg) {
Object.keys(msg).forEach(function (k) { Object.keys(msg).forEach(function (k) {
if (k === "log") { if (k === "log") {

21
assets/js/utils/fatal.js Normal file
View file

@ -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);
}

View file

@ -20,7 +20,6 @@ import PasswordModal from "../components/PasswordModal.vue";
import LoginModal from "../components/LoginModal.vue"; import LoginModal from "../components/LoginModal.vue";
import HelpModal from "../components/HelpModal.vue"; import HelpModal from "../components/HelpModal.vue";
import collector from "../mixins/collector"; import collector from "../mixins/collector";
import { updateAuthStatus } from "../auth";
// assume offline if not data received for 5 minutes // assume offline if not data received for 5 minutes
let lastDataReceived = new Date(); let lastDataReceived = new Date();
@ -61,9 +60,9 @@ export default {
this.reload(); this.reload();
} }
}, },
offline: function (now, prev) { offline: function (offline) {
updateAuthStatus(); store.offline(offline);
if (now && !prev) { if (offline) {
this.reconnect(); this.reconnect();
} }
}, },
@ -88,7 +87,6 @@ export default {
mounted: function () { mounted: function () {
this.connect(); this.connect();
document.addEventListener("visibilitychange", this.pageVisibilityChanged, false); document.addEventListener("visibilitychange", this.pageVisibilityChanged, false);
updateAuthStatus();
}, },
unmounted: function () { unmounted: function () {
this.disconnect(); this.disconnect();
@ -102,7 +100,6 @@ export default {
this.disconnect(); this.disconnect();
} else { } else {
this.connect(); this.connect();
updateAuthStatus();
} }
}, },
reconnect: function () { reconnect: function () {
@ -113,7 +110,6 @@ export default {
}, 2500); }, 2500);
}, },
disconnect: function () { disconnect: function () {
console.log("websocket disconnecting");
if (this.ws) { if (this.ws) {
this.ws.onerror = null; this.ws.onerror = null;
this.ws.onopen = null; this.ws.onopen = null;
@ -150,7 +146,7 @@ export default {
this.ws = new WebSocket(uri); this.ws = new WebSocket(uri);
this.ws.onerror = () => { this.ws.onerror = () => {
console.error({ message: "Websocket error. Trying to reconnect." }); console.log({ message: "Websocket error. Trying to reconnect." });
this.ws.close(); this.ws.close();
}; };
this.ws.onopen = () => { this.ws.onopen = () => {
@ -158,8 +154,8 @@ export default {
window.app.setOnline(); window.app.setOnline();
}; };
this.ws.onclose = () => { this.ws.onclose = () => {
console.log("websocket disconnected");
window.app.setOffline(); window.app.setOffline();
this.reconnect();
}; };
this.ws.onmessage = (evt) => { this.ws.onmessage = (evt) => {
try { try {

View file

@ -7,11 +7,11 @@ import (
"strings" "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 { func (i Class) String() string {
i -= 1 i -= 1
@ -25,78 +25,82 @@ func (i Class) String() string {
// Re-run the stringer command to generate them again. // Re-run the stringer command to generate them again.
func _ClassNoOp() { func _ClassNoOp() {
var x [1]struct{} var x [1]struct{}
_ = x[ClassMeter-(1)] _ = x[ClassConfigFile-(1)]
_ = x[ClassCharger-(2)] _ = x[ClassMeter-(2)]
_ = x[ClassVehicle-(3)] _ = x[ClassCharger-(3)]
_ = x[ClassTariff-(4)] _ = x[ClassVehicle-(4)]
_ = x[ClassCircuit-(5)] _ = x[ClassTariff-(5)]
_ = x[ClassSite-(6)] _ = x[ClassCircuit-(6)]
_ = x[ClassMqtt-(7)] _ = x[ClassSite-(7)]
_ = x[ClassDatabase-(8)] _ = x[ClassMqtt-(8)]
_ = x[ClassModbusProxy-(9)] _ = x[ClassDatabase-(9)]
_ = x[ClassEEBus-(10)] _ = x[ClassModbusProxy-(10)]
_ = x[ClassJavascript-(11)] _ = x[ClassEEBus-(11)]
_ = x[ClassGo-(12)] _ = x[ClassJavascript-(12)]
_ = x[ClassHEMS-(13)] _ = x[ClassGo-(13)]
_ = x[ClassInflux-(14)] _ = x[ClassHEMS-(14)]
_ = x[ClassMessenger-(15)] _ = x[ClassInflux-(15)]
_ = x[ClassSponsorship-(16)] _ = 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{ var _ClassNameToValueMap = map[string]Class{
_ClassName[0:5]: ClassMeter, _ClassName[0:10]: ClassConfigFile,
_ClassLowerName[0:5]: ClassMeter, _ClassLowerName[0:10]: ClassConfigFile,
_ClassName[5:12]: ClassCharger, _ClassName[10:15]: ClassMeter,
_ClassLowerName[5:12]: ClassCharger, _ClassLowerName[10:15]: ClassMeter,
_ClassName[12:19]: ClassVehicle, _ClassName[15:22]: ClassCharger,
_ClassLowerName[12:19]: ClassVehicle, _ClassLowerName[15:22]: ClassCharger,
_ClassName[19:25]: ClassTariff, _ClassName[22:29]: ClassVehicle,
_ClassLowerName[19:25]: ClassTariff, _ClassLowerName[22:29]: ClassVehicle,
_ClassName[25:32]: ClassCircuit, _ClassName[29:35]: ClassTariff,
_ClassLowerName[25:32]: ClassCircuit, _ClassLowerName[29:35]: ClassTariff,
_ClassName[32:36]: ClassSite, _ClassName[35:42]: ClassCircuit,
_ClassLowerName[32:36]: ClassSite, _ClassLowerName[35:42]: ClassCircuit,
_ClassName[36:40]: ClassMqtt, _ClassName[42:46]: ClassSite,
_ClassLowerName[36:40]: ClassMqtt, _ClassLowerName[42:46]: ClassSite,
_ClassName[40:48]: ClassDatabase, _ClassName[46:50]: ClassMqtt,
_ClassLowerName[40:48]: ClassDatabase, _ClassLowerName[46:50]: ClassMqtt,
_ClassName[48:59]: ClassModbusProxy, _ClassName[50:58]: ClassDatabase,
_ClassLowerName[48:59]: ClassModbusProxy, _ClassLowerName[50:58]: ClassDatabase,
_ClassName[59:64]: ClassEEBus, _ClassName[58:69]: ClassModbusProxy,
_ClassLowerName[59:64]: ClassEEBus, _ClassLowerName[58:69]: ClassModbusProxy,
_ClassName[64:74]: ClassJavascript, _ClassName[69:74]: ClassEEBus,
_ClassLowerName[64:74]: ClassJavascript, _ClassLowerName[69:74]: ClassEEBus,
_ClassName[74:76]: ClassGo, _ClassName[74:84]: ClassJavascript,
_ClassLowerName[74:76]: ClassGo, _ClassLowerName[74:84]: ClassJavascript,
_ClassName[76:80]: ClassHEMS, _ClassName[84:86]: ClassGo,
_ClassLowerName[76:80]: ClassHEMS, _ClassLowerName[84:86]: ClassGo,
_ClassName[80:86]: ClassInflux, _ClassName[86:90]: ClassHEMS,
_ClassLowerName[80:86]: ClassInflux, _ClassLowerName[86:90]: ClassHEMS,
_ClassName[86:95]: ClassMessenger, _ClassName[90:96]: ClassInflux,
_ClassLowerName[86:95]: ClassMessenger, _ClassLowerName[90:96]: ClassInflux,
_ClassName[95:106]: ClassSponsorship, _ClassName[96:105]: ClassMessenger,
_ClassLowerName[95:106]: ClassSponsorship, _ClassLowerName[96:105]: ClassMessenger,
_ClassName[105:116]: ClassSponsorship,
_ClassLowerName[105:116]: ClassSponsorship,
} }
var _ClassNames = []string{ var _ClassNames = []string{
_ClassName[0:5], _ClassName[0:10],
_ClassName[5:12], _ClassName[10:15],
_ClassName[12:19], _ClassName[15:22],
_ClassName[19:25], _ClassName[22:29],
_ClassName[25:32], _ClassName[29:35],
_ClassName[32:36], _ClassName[35:42],
_ClassName[36:40], _ClassName[42:46],
_ClassName[40:48], _ClassName[46:50],
_ClassName[48:59], _ClassName[50:58],
_ClassName[59:64], _ClassName[58:69],
_ClassName[64:74], _ClassName[69:74],
_ClassName[74:76], _ClassName[74:84],
_ClassName[76:80], _ClassName[84:86],
_ClassName[80:86], _ClassName[86:90],
_ClassName[86:95], _ClassName[90:96],
_ClassName[95:106], _ClassName[96:105],
_ClassName[105:116],
} }
// ClassString retrieves an enum value from the enum constants string name. // ClassString retrieves an enum value from the enum constants string name.

View file

@ -10,6 +10,7 @@ type Class int
//go:generate enumer -type Class -trimprefix Class -transform=lower -text //go:generate enumer -type Class -trimprefix Class -transform=lower -text
const ( const (
_ Class = iota _ Class = iota
ClassConfigFile
ClassMeter ClassMeter
ClassCharger ClassCharger
ClassVehicle ClassVehicle

View file

@ -119,7 +119,7 @@ func runRoot(cmd *cobra.Command, args []string) {
log.FATAL.Fatal(err) log.FATAL.Fatal(err)
} }
} else { } else {
err = cfgErr err = wrapErrorWithClass(ClassConfigFile, cfgErr)
} }
// setup environment // setup environment

View file

@ -486,6 +486,17 @@ func configureEnvironment(cmd *cobra.Command, conf *globalconfig.All) (err error
request.LogHeaders = true 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 // setup machine id
if conf.Plant != "" { if conf.Plant != "" {
// TODO decide wrapping // TODO decide wrapping
@ -497,17 +508,6 @@ func configureEnvironment(cmd *cobra.Command, conf *globalconfig.All) (err error
err = wrapErrorWithClass(ClassSponsorship, configureSponsorship(conf.SponsorToken)) 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 // setup mqtt client listener
if err == nil { if err == nil {
err = wrapErrorWithClass(ClassMqtt, configureMqtt(&conf.Mqtt)) err = wrapErrorWithClass(ClassMqtt, configureMqtt(&conf.Mqtt))
@ -539,6 +539,10 @@ func configureEnvironment(cmd *cobra.Command, conf *globalconfig.All) (err error
// configureDatabase configures session database // configureDatabase configures session database
func configureDatabase(conf globalconfig.DB) error { 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 { if err := db.NewInstance(conf.Type, conf.Dsn); err != nil {
return err return err
} }

View file

@ -8,7 +8,7 @@ export default defineConfig({
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
timeout: 15000, // 15s (default 30s) timeout: 15000, // 15s (default 30s)
workers: process.env.CI ? 3 : 8, workers: process.env.CI ? 3 : 4,
reporter: "html", reporter: "html",
use: { use: {
baseURL: "http://127.0.0.1:7070", baseURL: "http://127.0.0.1:7070",

View file

@ -3,16 +3,12 @@ import { start, stop, baseUrl } from "./evcc";
test.use({ baseURL: baseUrl() }); test.use({ baseURL: baseUrl() });
test.beforeEach(async ({ page }) => { const BASIC = "basics.evcc.yaml";
await start("basics.evcc.yaml");
await page.goto("/");
});
test.afterEach(async () => {
await stop();
});
test("set initial password", async ({ page }) => { test("set initial password", async ({ page }) => {
await start(BASIC);
await page.goto("/");
const modal = page.getByTestId("password-modal"); const modal = page.getByTestId("password-modal");
await expect(modal).toBeVisible(); await expect(modal).toBeVisible();
@ -33,14 +29,13 @@ test("set initial password", async ({ page }) => {
await modal.getByLabel("Repeat password").fill("secret"); await modal.getByLabel("Repeat password").fill("secret");
await modal.getByRole("button", { name: "Create Password" }).click(); await modal.getByRole("button", { name: "Create Password" }).click();
await expect(modal).not.toBeVisible(); await expect(modal).not.toBeVisible();
await stop();
}); });
test("login", async ({ page }) => { test("login", async ({ page }) => {
// set initial password await start(BASIC, "password.sql");
const modal = page.getByTestId("password-modal"); await page.goto("/");
await modal.getByLabel("New password").fill("secret");
await modal.getByLabel("Repeat password").fill("secret");
await modal.getByRole("button", { name: "Create Password" }).click();
// go to config // go to config
await page.getByTestId("topnavigation-button").click(); await page.getByTestId("topnavigation-button").click();
@ -61,14 +56,13 @@ test("login", async ({ page }) => {
await login.getByRole("button", { name: "Login" }).click(); await login.getByRole("button", { name: "Login" }).click();
await expect(login).not.toBeVisible(); await expect(login).not.toBeVisible();
await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
await stop();
}); });
test("http iframe hint", async ({ page }) => { test("http iframe hint", async ({ page }) => {
// set initial password await start(BASIC, "password.sql");
const modal = page.getByTestId("password-modal"); await page.goto("/");
await modal.getByLabel("New password").fill("secret");
await modal.getByLabel("Repeat password").fill("secret");
await modal.getByRole("button", { name: "Create Password" }).click();
// go to config // go to config
await page.getByTestId("topnavigation-button").click(); await page.getByTestId("topnavigation-button").click();
@ -90,26 +84,27 @@ test("http iframe hint", async ({ page }) => {
// iframe hint visible (login-iframe-hint) // iframe hint visible (login-iframe-hint)
await expect(login.getByTestId("login-iframe-hint")).toBeVisible(); await expect(login.getByTestId("login-iframe-hint")).toBeVisible();
await stop();
}); });
test("update password", async ({ page }) => { test("update password", async ({ page }) => {
const instance = await start(BASIC, "password.sql");
await page.goto("/");
const oldPassword = "secret"; const oldPassword = "secret";
const newPassword = "newsecret"; 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 // login modal
page.goto("/#/config"); page.goto("/#/config");
const loginOld = page.getByTestId("login-modal"); const loginOld = page.getByTestId("login-modal");
await loginOld.getByLabel("Password").fill(oldPassword); await loginOld.getByLabel("Password").fill(oldPassword);
await loginOld.getByRole("button", { name: "Login" }).click(); await loginOld.getByRole("button", { name: "Login" }).click();
await expect(loginOld).not.toBeVisible();
// update password // update password
await page.getByTestId("generalconfig-password").getByRole("button", { name: "edit" }).click(); 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 expect(modal.getByRole("heading", { name: "Update Administrator Password" })).toBeVisible();
await modal.getByLabel("Current password").fill(oldPassword); await modal.getByLabel("Current password").fill(oldPassword);
await modal.getByLabel("New password").fill(newPassword); 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(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
await expect(loginNew).not.toBeVisible(); await expect(loginNew).not.toBeVisible();
// revert password // hard stop, since password is updated
await page.getByTestId("generalconfig-password").getByRole("button", { name: "edit" }).click(); await stop(instance);
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();
}); });

View file

@ -18,6 +18,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
await page.locator("#loginPassword").fill("secret"); await page.locator("#loginPassword").fill("secret");
await page.getByRole("button", { name: "Login" }).click(); await page.getByRole("button", { name: "Login" }).click();
await expect(page.locator("#loginPassword")).not.toBeVisible();
} }
async function enableExperimental(page) { async function enableExperimental(page) {

View file

@ -18,6 +18,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
await page.locator("#loginPassword").fill("secret"); await page.locator("#loginPassword").fill("secret");
await page.getByRole("button", { name: "Login" }).click(); await page.getByRole("button", { name: "Login" }).click();
await expect(page.locator("#loginPassword")).not.toBeVisible();
} }
async function enableExperimental(page) { async function enableExperimental(page) {

View file

@ -19,6 +19,7 @@ test.afterEach(async () => {
async function login(page) { async function login(page) {
await page.locator("#loginPassword").fill("secret"); await page.locator("#loginPassword").fill("secret");
await page.getByRole("button", { name: "Login" }).click(); await page.getByRole("button", { name: "Login" }).click();
await expect(page.locator("#loginPassword")).not.toBeVisible();
} }
async function enableExperimental(page) { async function enableExperimental(page) {

View file

@ -15,6 +15,7 @@ const SELECT_ALL = "ControlOrMeta+KeyA";
async function login(page) { async function login(page) {
await page.locator("#loginPassword").fill("secret"); await page.locator("#loginPassword").fill("secret");
await page.getByRole("button", { name: "Login" }).click(); await page.getByRole("button", { name: "Login" }).click();
await expect(page.locator("#loginPassword")).not.toBeVisible();
} }
async function enableExperimental(page) { async function enableExperimental(page) {

View file

@ -16,6 +16,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
await page.locator("#loginPassword").fill("secret"); await page.locator("#loginPassword").fill("secret");
await page.getByRole("button", { name: "Login" }).click(); await page.getByRole("button", { name: "Login" }).click();
await expect(page.locator("#loginPassword")).not.toBeVisible();
} }
async function enableExperimental(page) { async function enableExperimental(page) {

View file

@ -15,6 +15,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
await page.locator("#loginPassword").fill("secret"); await page.locator("#loginPassword").fill("secret");
await page.getByRole("button", { name: "Login" }).click(); await page.getByRole("button", { name: "Login" }).click();
await expect(page.locator("#loginPassword")).not.toBeVisible();
} }
async function enableExperimental(page) { async function enableExperimental(page) {

View file

@ -7,17 +7,21 @@ import path from "path";
const BINARY = "./evcc"; const BINARY = "./evcc";
function port() { function workerPort() {
const index = process.env.TEST_WORKER_INDEX * 1; const index = process.env.TEST_WORKER_INDEX * 1;
return 11000 + index; return 11000 + index;
} }
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function baseUrl() { export function baseUrl() {
return `http://localhost:${port()}`; return `http://localhost:${workerPort()}`;
} }
function dbPath() { function dbPath() {
const file = `evcc-${port()}.db`; const file = `evcc-${workerPort()}.db`;
return path.join(os.tmpdir(), file); return path.join(os.tmpdir(), file);
} }
@ -26,11 +30,11 @@ export async function start(config, sqlDumps) {
if (sqlDumps) { if (sqlDumps) {
await _restoreDatabase(sqlDumps); await _restoreDatabase(sqlDumps);
} }
await _start(config); return await _start(config);
} }
export async function stop() { export async function stop(instance) {
await _stop(); await _stop(instance);
await _clean(); await _clean();
} }
@ -58,27 +62,39 @@ async function _restoreDatabase(sqlDumps) {
async function _start(config) { async function _start(config) {
const configFile = config.includes("/") ? config : `tests/${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( 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.stdout.pipe(process.stdout);
instance.stderr.pipe(process.stderr); instance.stderr.pipe(process.stderr);
instance.on("exit", (code) => { instance.on("exit", (code) => {
console.log("evcc terminated", code); console.log("evcc terminated", { code, port, config });
}); });
await waitOn({ resources: [baseUrl()] }); await waitOn({ resources: [baseUrl()] });
return instance;
} }
async function _stop() { async function _stop(instance) {
console.log("shutting down evcc"); 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" }); const res = await axios.post(`${baseUrl()}/api/auth/login`, { password: "secret" });
console.log(res.status, res.statusText); console.log(res.status, res.statusText);
const cookie = res.headers["set-cookie"]; const cookie = res.headers["set-cookie"];
await axios.post(`${baseUrl()}/api/system/shutdown`, {}, { headers: { cookie } }); await axios.post(`${baseUrl()}/api/system/shutdown`, {}, { headers: { cookie } });
console.log("wait until network port is closed"); console.log(`wait until port ${port} is closed`);
await waitOn({ resources: [`tcp:localhost:${port()}`], reverse: true }); await waitOn({ resources: [`tcp:localhost:${port}`], reverse: true });
console.log("evcc is down"); console.log("evcc is down", { port });
} }
async function _clean() { async function _clean() {

6
tests/fatal-db.evcc.yaml Executable file
View file

@ -0,0 +1,6 @@
site:
title: Hello World
database:
type: sqliteInvalid
dsn: /path/to/db

2
tests/fatal-syntax.evcc.yaml Executable file
View file

@ -0,0 +1,2 @@
s!ite:
title: Hello World

23
tests/fatal.spec.js Normal file
View file

@ -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);
});
});

View file

@ -13,6 +13,7 @@ test.afterAll(async () => {
async function login(page) { async function login(page) {
await page.locator("#loginPassword").fill("secret"); await page.locator("#loginPassword").fill("secret");
await page.getByRole("button", { name: "Login" }).click(); await page.getByRole("button", { name: "Login" }).click();
await expect(page.locator("#loginPassword")).not.toBeVisible();
} }
test.describe("opening logs", async () => { test.describe("opening logs", async () => {

View file

@ -11,6 +11,7 @@ test.use({ baseURL: baseUrl() });
async function login(page) { async function login(page) {
await page.locator("#loginPassword").fill("secret"); await page.locator("#loginPassword").fill("secret");
await page.getByRole("button", { name: "Login" }).click(); await page.getByRole("button", { name: "Login" }).click();
await expect(page.locator("#loginPassword")).not.toBeVisible();
} }
test.describe("Basics", async () => { test.describe("Basics", async () => {