Startup Health-Check: proaktive Warnungen bei Problem-Zustaenden
Laeuft einmal beim App-Start und warnt klar im Log, wenn etwas auffaellt — BEVOR der Nutzer mitten im Download stolpert. Blockiert den Start nicht, schreibt nur in rd_downloader.log + audit-log. Pruefungen: - Download-Ziel-Ordner fehlt / nicht beschreibbar / nicht konfiguriert - Runtime-Ordner (%APPDATA%/runtime) fehlt oder nicht beschreibbar - Wenig Festplattenplatz im Download-Ordner (< 5 GB) - Kein einziger Debrid-Provider konfiguriert → Downloads koennen nicht funktionieren - State-Datei > 50 MB (alte abgeschlossene Pakete sollten geprunt werden) Listet zudem als INFO alle aktiv konfigurierten Provider auf, damit aus dem Startup-Log klar ist was aktiv ist (Mega-Debrid X Accounts, Debrid-Link X Keys, etc.). Reine Funktion runStartupHealthCheck() → HealthCheckReport, 6 Unit-Tests decken die wichtigsten Pfade ab. Wiring in AppController-Constructor ist in try/catch — falls der Check selbst abstuerzt, stoert das den Start nicht.
This commit is contained in:
parent
a1697e652e
commit
90f347dc2b
@ -38,6 +38,7 @@ import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-ser
|
|||||||
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
||||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
||||||
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
|
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
|
||||||
|
import { runStartupHealthCheck } from "./startup-health-check";
|
||||||
import { getDebugSetupCheck } from "./debug-setup";
|
import { getDebugSetupCheck } from "./debug-setup";
|
||||||
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
|
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
|
||||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
|
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
|
||||||
@ -117,6 +118,38 @@ export class AppController {
|
|||||||
appVersion: APP_VERSION,
|
appVersion: APP_VERSION,
|
||||||
runtimeDir: this.storagePaths.baseDir
|
runtimeDir: this.storagePaths.baseDir
|
||||||
});
|
});
|
||||||
|
// Startup Health-Check: surface problematic state early (missing download
|
||||||
|
// dir, low disk space, no provider configured, corrupted state file).
|
||||||
|
// Never blocks startup — findings go into the normal log + audit log so
|
||||||
|
// the user can diagnose issues before hitting them mid-download.
|
||||||
|
try {
|
||||||
|
const report = runStartupHealthCheck(this.settings, this.storagePaths);
|
||||||
|
if (report.errorCount > 0 || report.warnCount > 0) {
|
||||||
|
logger.warn(`Health-Check: ${report.errorCount} Fehler, ${report.warnCount} Warnungen, ${report.infoCount} Info`);
|
||||||
|
} else {
|
||||||
|
logger.info(`Health-Check: alles OK (${report.infoCount} Info)`);
|
||||||
|
}
|
||||||
|
for (const finding of report.findings) {
|
||||||
|
const line = finding.hint
|
||||||
|
? `Health-Check [${finding.code}]: ${finding.message} — ${finding.hint}`
|
||||||
|
: `Health-Check [${finding.code}]: ${finding.message}`;
|
||||||
|
if (finding.severity === "ERROR") {
|
||||||
|
logger.error(line);
|
||||||
|
} else if (finding.severity === "WARN") {
|
||||||
|
logger.warn(line);
|
||||||
|
} else {
|
||||||
|
logger.info(line);
|
||||||
|
}
|
||||||
|
if (finding.severity !== "INFO") {
|
||||||
|
logAuditEvent(finding.severity, `Health-Check: ${finding.code}`, {
|
||||||
|
message: finding.message,
|
||||||
|
hint: finding.hint || ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Health-Check uebersprungen (Fehler): ${String((err as Error).message || err)}`);
|
||||||
|
}
|
||||||
startDebugServer(this.manager, this.storagePaths.baseDir);
|
startDebugServer(this.manager, this.storagePaths.baseDir);
|
||||||
this.runtimeStatsTimer = setInterval(() => {
|
this.runtimeStatsTimer = setInterval(() => {
|
||||||
this.manager.persistRuntimeStats();
|
this.manager.persistRuntimeStats();
|
||||||
|
|||||||
220
src/main/startup-health-check.ts
Normal file
220
src/main/startup-health-check.ts
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { AppSettings } from "../shared/types";
|
||||||
|
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||||
|
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||||
|
import { StoragePaths } from "./storage";
|
||||||
|
|
||||||
|
/** Startup Health-Check: runs once at app boot and surfaces potential problem
|
||||||
|
* states BEFORE the user hits them mid-download.
|
||||||
|
*
|
||||||
|
* Goals:
|
||||||
|
* - Warn on missing / unreachable download directory
|
||||||
|
* - Warn on low disk space (< 5 GB free)
|
||||||
|
* - Warn when no debrid provider is configured (app is effectively offline)
|
||||||
|
* - Warn when state file is suspiciously large (>50 MB → pruning recommended)
|
||||||
|
*
|
||||||
|
* Non-goals: blocking startup. The check only logs — the app continues. */
|
||||||
|
|
||||||
|
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
|
||||||
|
|
||||||
|
export interface HealthCheckFinding {
|
||||||
|
severity: HealthCheckSeverity;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthCheckReport {
|
||||||
|
findings: HealthCheckFinding[];
|
||||||
|
errorCount: number;
|
||||||
|
warnCount: number;
|
||||||
|
infoCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB
|
||||||
|
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024; // 50 MB
|
||||||
|
|
||||||
|
function safeExists(p: string): boolean {
|
||||||
|
try {
|
||||||
|
return fs.existsSync(p);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileSizeBytes(p: string): number {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(p);
|
||||||
|
return stat.size;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attempt a tiny write-probe in the given directory. Returns true on
|
||||||
|
* success, false if the directory isn't writable. We write and immediately
|
||||||
|
* delete a uniquely-named temp file so we never leave garbage behind. */
|
||||||
|
function isWritable(dir: string): boolean {
|
||||||
|
const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(probe, "x", { encoding: "utf8" });
|
||||||
|
fs.rmSync(probe, { force: true });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query free disk space for a given path. Returns null if unsupported or
|
||||||
|
* the query fails — callers treat null as "unknown" and skip the check. */
|
||||||
|
function getFreeDiskSpaceBytes(target: string): number | null {
|
||||||
|
try {
|
||||||
|
// fs.statfsSync is available on Node 18.15+; on Windows it still maps to
|
||||||
|
// the underlying volume so it works for download dirs on any drive.
|
||||||
|
const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync;
|
||||||
|
if (typeof statfs !== "function") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const result = statfs(target);
|
||||||
|
const bavail = BigInt(result.bavail);
|
||||||
|
const bsize = BigInt(result.bsize);
|
||||||
|
const free = bavail * bsize;
|
||||||
|
if (free > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||||
|
return Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
return Number(free);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countConfiguredProviders(settings: AppSettings): { count: number; providers: string[] } {
|
||||||
|
const providers: string[] = [];
|
||||||
|
if (settings.token?.trim() || settings.realDebridUseWebLogin) {
|
||||||
|
providers.push("Real-Debrid");
|
||||||
|
}
|
||||||
|
if (settings.allDebridToken?.trim() || settings.allDebridUseWebLogin) {
|
||||||
|
providers.push("AllDebrid");
|
||||||
|
}
|
||||||
|
if (settings.bestToken?.trim() || settings.bestDebridUseWebLogin) {
|
||||||
|
providers.push("BestDebrid");
|
||||||
|
}
|
||||||
|
if (settings.oneFichierApiKey?.trim()) {
|
||||||
|
providers.push("1Fichier");
|
||||||
|
}
|
||||||
|
if (settings.ddownloadLogin?.trim() && settings.ddownloadPassword?.trim()) {
|
||||||
|
providers.push("DDownload");
|
||||||
|
}
|
||||||
|
if (settings.linkSnappyLogin?.trim() && settings.linkSnappyPassword?.trim()) {
|
||||||
|
providers.push("LinkSnappy");
|
||||||
|
}
|
||||||
|
const dlKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
||||||
|
if (dlKeys.length > 0) {
|
||||||
|
providers.push(`Debrid-Link (${dlKeys.length} Key${dlKeys.length === 1 ? "" : "s"})`);
|
||||||
|
}
|
||||||
|
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
|
||||||
|
const legacyMegaConfigured = Boolean(settings.megaLogin?.trim() && settings.megaPassword?.trim());
|
||||||
|
if (megaAccounts.length > 0) {
|
||||||
|
providers.push(`Mega-Debrid (${megaAccounts.length} Acc)`);
|
||||||
|
} else if (legacyMegaConfigured) {
|
||||||
|
providers.push("Mega-Debrid");
|
||||||
|
}
|
||||||
|
return { count: providers.length, providers };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure check function: takes inputs, returns findings. Kept side-effect-free
|
||||||
|
* so it's trivial to unit-test — the caller handles logging / persistence. */
|
||||||
|
export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport {
|
||||||
|
const findings: HealthCheckFinding[] = [];
|
||||||
|
|
||||||
|
// ── 1. Download directory ───────────────────────────────────────────────
|
||||||
|
const outputDir = String(settings.outputDir || "").trim();
|
||||||
|
if (!outputDir) {
|
||||||
|
findings.push({
|
||||||
|
severity: "WARN",
|
||||||
|
code: "outputDir_missing",
|
||||||
|
message: "Kein Download-Ziel-Verzeichnis konfiguriert",
|
||||||
|
hint: "In den Einstellungen unter 'Downloads' einen Ziel-Ordner setzen, sonst koennen keine Downloads starten."
|
||||||
|
});
|
||||||
|
} else if (!safeExists(outputDir)) {
|
||||||
|
findings.push({
|
||||||
|
severity: "WARN",
|
||||||
|
code: "outputDir_not_found",
|
||||||
|
message: `Download-Ziel-Ordner existiert nicht: ${outputDir}`,
|
||||||
|
hint: "Der Ordner wird beim ersten Download automatisch erstellt, sofern der Elternordner existiert und beschreibbar ist."
|
||||||
|
});
|
||||||
|
} else if (!isWritable(outputDir)) {
|
||||||
|
findings.push({
|
||||||
|
severity: "ERROR",
|
||||||
|
code: "outputDir_not_writable",
|
||||||
|
message: `Download-Ziel-Ordner ist NICHT beschreibbar: ${outputDir}`,
|
||||||
|
hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern."
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Check available disk space only when the directory is actually usable
|
||||||
|
const freeBytes = getFreeDiskSpaceBytes(outputDir);
|
||||||
|
if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) {
|
||||||
|
const freeMb = Math.round(freeBytes / (1024 * 1024));
|
||||||
|
findings.push({
|
||||||
|
severity: "WARN",
|
||||||
|
code: "low_disk_space",
|
||||||
|
message: `Wenig freier Speicher im Download-Ordner: ~${freeMb} MB verfuegbar (Schwelle ${LOW_DISK_SPACE_BYTES / (1024 * 1024 * 1024)} GB)`,
|
||||||
|
hint: "Groessere Downloads koennen auf halbem Weg fehlschlagen. Vorher Platz schaffen oder anderen Ordner waehlen."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Provider-Credentials ─────────────────────────────────────────────
|
||||||
|
const { count, providers } = countConfiguredProviders(settings);
|
||||||
|
if (count === 0) {
|
||||||
|
findings.push({
|
||||||
|
severity: "WARN",
|
||||||
|
code: "no_provider_configured",
|
||||||
|
message: "Kein Debrid-Provider konfiguriert — Downloads werden nicht funktionieren",
|
||||||
|
hint: "In den Einstellungen mindestens einen Provider (Real-Debrid, Mega-Debrid, Debrid-Link, ...) einrichten."
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
findings.push({
|
||||||
|
severity: "INFO",
|
||||||
|
code: "providers_configured",
|
||||||
|
message: `Konfigurierte Provider: ${providers.join(", ")}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. State-File-Groesse ──────────────────────────────────────────────
|
||||||
|
if (safeExists(storagePaths.sessionFile)) {
|
||||||
|
const sizeBytes = getFileSizeBytes(storagePaths.sessionFile);
|
||||||
|
if (sizeBytes > LARGE_STATE_FILE_BYTES) {
|
||||||
|
const sizeMb = Math.round(sizeBytes / (1024 * 1024));
|
||||||
|
findings.push({
|
||||||
|
severity: "WARN",
|
||||||
|
code: "large_state_file",
|
||||||
|
message: `State-Datei ist sehr gross: ${sizeMb} MB (${path.basename(storagePaths.sessionFile)})`,
|
||||||
|
hint: "Alte abgeschlossene Pakete aus der Queue entfernen, damit Startup + Save schneller werden."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Storage-Basis-Verzeichnis muss beschreibbar sein (fuer Logs) ────
|
||||||
|
if (!safeExists(storagePaths.baseDir)) {
|
||||||
|
findings.push({
|
||||||
|
severity: "ERROR",
|
||||||
|
code: "baseDir_missing",
|
||||||
|
message: `Runtime-Verzeichnis existiert nicht: ${storagePaths.baseDir}`,
|
||||||
|
hint: "Ohne Runtime-Verzeichnis koennen weder Settings noch Session-State persistiert werden."
|
||||||
|
});
|
||||||
|
} else if (!isWritable(storagePaths.baseDir)) {
|
||||||
|
findings.push({
|
||||||
|
severity: "ERROR",
|
||||||
|
code: "baseDir_not_writable",
|
||||||
|
message: `Runtime-Verzeichnis ist NICHT beschreibbar: ${storagePaths.baseDir}`,
|
||||||
|
hint: "Rechte auf das Runtime-Verzeichnis pruefen (%APPDATA%/Real-Debrid-Downloader/runtime)."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorCount = findings.filter((f) => f.severity === "ERROR").length;
|
||||||
|
const warnCount = findings.filter((f) => f.severity === "WARN").length;
|
||||||
|
const infoCount = findings.filter((f) => f.severity === "INFO").length;
|
||||||
|
return { findings, errorCount, warnCount, infoCount };
|
||||||
|
}
|
||||||
137
tests/startup-health-check.test.ts
Normal file
137
tests/startup-health-check.test.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { defaultSettings } from "../src/main/constants";
|
||||||
|
import { createStoragePaths } from "../src/main/storage";
|
||||||
|
import { runStartupHealthCheck } from "../src/main/startup-health-check";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeTempBase(): { baseDir: string; outputDir: string; paths: ReturnType<typeof createStoragePaths> } {
|
||||||
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-"));
|
||||||
|
tempDirs.push(baseDir);
|
||||||
|
const outputDir = path.join(baseDir, "downloads");
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
return {
|
||||||
|
baseDir: path.join(baseDir, "runtime"),
|
||||||
|
outputDir,
|
||||||
|
paths: createStoragePaths(path.join(baseDir, "runtime"))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runStartupHealthCheck", () => {
|
||||||
|
it("flags missing download directory", () => {
|
||||||
|
const { outputDir, paths } = makeTempBase();
|
||||||
|
fs.mkdirSync(paths.baseDir, { recursive: true });
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(outputDir, "does-not-exist-subdir")
|
||||||
|
};
|
||||||
|
const report = runStartupHealthCheck(settings, paths);
|
||||||
|
const codes = report.findings.map((f) => f.code);
|
||||||
|
expect(codes).toContain("outputDir_not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags no-provider-configured when all credentials are empty", () => {
|
||||||
|
const { outputDir, paths } = makeTempBase();
|
||||||
|
fs.mkdirSync(paths.baseDir, { recursive: true });
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "",
|
||||||
|
megaLogin: "",
|
||||||
|
megaPassword: "",
|
||||||
|
megaCredentials: "",
|
||||||
|
allDebridToken: "",
|
||||||
|
bestToken: "",
|
||||||
|
oneFichierApiKey: "",
|
||||||
|
debridLinkApiKeys: "",
|
||||||
|
outputDir
|
||||||
|
};
|
||||||
|
const report = runStartupHealthCheck(settings, paths);
|
||||||
|
const codes = report.findings.map((f) => f.code);
|
||||||
|
expect(codes).toContain("no_provider_configured");
|
||||||
|
expect(report.warnCount).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports configured providers when at least one credential is set", () => {
|
||||||
|
const { outputDir, paths } = makeTempBase();
|
||||||
|
fs.mkdirSync(paths.baseDir, { recursive: true });
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token-here",
|
||||||
|
debridLinkApiKeys: "dl-key-a\ndl-key-b",
|
||||||
|
outputDir
|
||||||
|
};
|
||||||
|
const report = runStartupHealthCheck(settings, paths);
|
||||||
|
const providersFinding = report.findings.find((f) => f.code === "providers_configured");
|
||||||
|
expect(providersFinding).toBeDefined();
|
||||||
|
expect(providersFinding?.message).toContain("Real-Debrid");
|
||||||
|
expect(providersFinding?.message).toContain("Debrid-Link");
|
||||||
|
expect(providersFinding?.message).toContain("2 Keys");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags large state files", () => {
|
||||||
|
const { outputDir, paths } = makeTempBase();
|
||||||
|
fs.mkdirSync(paths.baseDir, { recursive: true });
|
||||||
|
// 60 MB dummy state file, threshold is 50 MB
|
||||||
|
fs.writeFileSync(paths.sessionFile, Buffer.alloc(60 * 1024 * 1024, 0));
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir
|
||||||
|
};
|
||||||
|
const report = runStartupHealthCheck(settings, paths);
|
||||||
|
const codes = report.findings.map((f) => f.code);
|
||||||
|
expect(codes).toContain("large_state_file");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags missing base dir as ERROR", () => {
|
||||||
|
const { outputDir, paths } = makeTempBase();
|
||||||
|
// Intentionally DON'T create baseDir.
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir
|
||||||
|
};
|
||||||
|
const report = runStartupHealthCheck(settings, paths);
|
||||||
|
const codes = report.findings.map((f) => f.code);
|
||||||
|
expect(codes).toContain("baseDir_missing");
|
||||||
|
expect(report.errorCount).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes cleanly when everything is healthy", () => {
|
||||||
|
const { outputDir, paths } = makeTempBase();
|
||||||
|
fs.mkdirSync(paths.baseDir, { recursive: true });
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token-here",
|
||||||
|
outputDir
|
||||||
|
};
|
||||||
|
const report = runStartupHealthCheck(settings, paths);
|
||||||
|
expect(report.errorCount).toBe(0);
|
||||||
|
const codes = report.findings.map((f) => f.code);
|
||||||
|
expect(codes).not.toContain("outputDir_not_found");
|
||||||
|
expect(codes).not.toContain("outputDir_not_writable");
|
||||||
|
expect(codes).not.toContain("no_provider_configured");
|
||||||
|
expect(codes).not.toContain("baseDir_missing");
|
||||||
|
expect(codes).not.toContain("baseDir_not_writable");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user