evcc-io/tests/evcc.ts
2025-09-16 14:15:11 +02:00

174 lines
5 KiB
TypeScript

import fs from "fs";
import waitOn from "wait-on";
import axios from "axios";
import { spawn, execSync, ChildProcess } from "child_process";
import killPort from "kill-port";
import os from "os";
import path from "path";
import { Transform } from "stream";
import { test } from "@playwright/test";
const BINARY = "./evcc";
const IS_CI = !!process.env["GITHUB_ACTIONS"];
const LOG_ENABLED = !IS_CI;
// sometimes evcc startup fails due to infra issues in runner ususally fixed by retry. allowing some fails to avoid github annotations clutter
let allowedStartupFails = IS_CI ? 2 : 0;
function workerPort() {
const index = Number(process.env["TEST_WORKER_INDEX"] ?? 0);
return 11000 + index;
}
function logPrefix() {
return `[worker:${process.env["TEST_WORKER_INDEX"]}]`;
}
function createSteamLog() {
return new Transform({
transform(chunk: Buffer, _, callback) {
const lines = chunk.toString().split("\n");
lines.forEach((line: string) => {
if (line.trim()) log(line);
});
callback();
},
});
}
function log(...args: any[]) {
if (LOG_ENABLED) {
console.log(logPrefix(), ...args);
}
}
export function baseUrl() {
return `http://localhost:${workerPort()}`;
}
function dbPath() {
const file = `evcc-${workerPort()}.db`;
return path.join(os.tmpdir(), file);
}
export async function start(
config?: string,
sqlDumps?: string | null,
flags: string | string[] = "--disable-auth"
) {
await _clean();
if (sqlDumps) {
await _restoreDatabase(sqlDumps);
}
return await _start(config, flags);
}
export async function stop(instance?: ChildProcess) {
await _stop(instance);
await _clean();
}
export async function restart(config?: string, flags = "--disable-auth", alreadyStopped = false) {
if (!alreadyStopped) {
await _stop();
}
await _start(config, flags);
}
export async function cleanRestart(config: string, sqlDumps: string) {
await _stop();
await _clean();
if (sqlDumps) {
await _restoreDatabase(sqlDumps);
}
await _start(config);
}
async function _restoreDatabase(sqlDumps: string) {
const dumps = Array.isArray(sqlDumps) ? sqlDumps : [sqlDumps];
for (const dump of dumps) {
log("loading database", dbPath(), dump);
execSync(`sqlite3 ${dbPath()} < tests/${dump}`);
}
}
async function _start(config?: string, flags: string | string[] = []) {
const configArgs = config ? ["--config", config.includes("/") ? config : `tests/${config}`] : [];
const port = workerPort();
log(`wait until port ${port} is available`);
// wait for port to be available
await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED });
const additionalFlags = typeof flags === "string" ? [flags] : flags;
additionalFlags.push("--log", "debug,httpd:trace");
log("starting evcc", { config, port, additionalFlags });
const instance = spawn(BINARY, [...configArgs, ...additionalFlags], {
env: { EVCC_NETWORK_PORT: port.toString(), EVCC_DATABASE_DSN: dbPath() },
stdio: ["pipe", "pipe", "pipe"],
});
const steamLog = createSteamLog();
instance.stdout.pipe(steamLog);
instance.stderr.pipe(steamLog);
instance.on("exit", (code) => {
log("evcc terminated", { code, port, config });
steamLog.end();
});
try {
await waitOn({ resources: [baseUrl()], log: LOG_ENABLED, timeout: 50000 });
} catch (error) {
instance.kill("SIGKILL");
console.error(logPrefix(), `evcc startup failed: ${error}`);
if (allowedStartupFails > 0) {
allowedStartupFails--;
test.skip(true, `evcc startup timeout (${allowedStartupFails} skips remaining)`);
return;
}
throw error;
}
return instance;
}
async function _stop(instance?: ChildProcess) {
const port = workerPort();
if (instance) {
log("shutting down evcc hard", { port });
instance.kill("SIGKILL");
await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED });
log("evcc is down", { port });
return;
}
let cookie;
try {
// check if auth is required
const res = await axios.get(`${baseUrl()}/api/auth/status`);
log("auth status", res.status, res.statusText, res.data);
// login required
if (!res.data) {
const res = await axios.post(`${baseUrl()}/api/auth/login`, { password: "secret" });
log("login", res.status, res.statusText);
cookie = res.headers["set-cookie"];
}
log("shutting down evcc", { port });
await axios.post(`${baseUrl()}/api/system/shutdown`, {}, { headers: { cookie } });
} catch (error) {
const port = workerPort();
log(`shutdown failed, last resort: kill by port`, port, error);
try {
await killPort(port);
log(`killed process on port ${port}`);
} catch (killError) {
log(`no process found on port ${port} or kill failed:`, killError);
}
}
log(`wait until port ${port} is closed`);
await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED });
log("evcc is down", { port });
}
async function _clean() {
const db = dbPath();
if (fs.existsSync(db)) {
log("delete database", db);
fs.unlinkSync(db);
}
}