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 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;

View file

@ -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) {

View file

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

View file

@ -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") {

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 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 {

View file

@ -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.

View file

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

View file

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

View file

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

View file

@ -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",

View file

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

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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() {

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) {
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 () => {

View file

@ -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 () => {