diff --git a/apps/server/src/__tests__/config.test.ts b/apps/server/src/__tests__/config.test.ts index c294bf2..d14f05d 100644 --- a/apps/server/src/__tests__/config.test.ts +++ b/apps/server/src/__tests__/config.test.ts @@ -1,4 +1,6 @@ import { expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { BASE_PATH, resolveFromBase, toStoredPath } from "../config.ts"; @@ -21,3 +23,25 @@ test("toStoredPath - files outside BASE_PATH are stored absolute", () => { expect(toStoredPath("/etc/snapraid.conf")).toEqual("/etc/snapraid.conf"); expect(toStoredPath("../outside.conf")).toEqual(resolveFromBase("../outside.conf")); }); + +// BASE_PATH is read once, when config.ts is imported: the first start runs in a process of its own +test("loadAppConfig - the first start creates the data directory and saves the default config", async () => { + const dir = await mkdtemp(join(tmpdir(), "snapraid-ui-first-start-")); + const base = join(dir, "snapraid"); + try { + const proc = Bun.spawn( + [process.execPath, "-e", `import { loadAppConfig } from ${JSON.stringify(join(import.meta.dir, "..", "config-parser.ts"))}; await loadAppConfig();`], + { env: { ...process.env, SNAPRAID_BASE_PATH: base }, stdout: "pipe", stderr: "pipe" }, + ); + const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]); + + expect(stderr).not.toContain("Failed to save default config"); + expect(exitCode).toBe(0); + // As the log directory and every other directory the backend makes: mkdir's default, less the umask + expect((await stat(base)).mode & 0o777).toBe(0o777 & ~process.umask()); + const saved = JSON.parse(await readFile(join(base, "config.json"), "utf8")); + expect(saved.snapraidConfigs[0].path).toBe("snapraid.conf"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/apps/server/src/config-parser.ts b/apps/server/src/config-parser.ts index 4ff3537..38ba62f 100644 --- a/apps/server/src/config-parser.ts +++ b/apps/server/src/config-parser.ts @@ -1,9 +1,9 @@ -import { basename, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import type { AppConfig, ParityLevel, ParsedSnapRaidConfig } from "@shared/types.ts"; import { resolveFromBase } from "./config.ts"; import { effectiveSmartIgnore, parseSmartIgnoreLine } from "./smart-ignore.ts"; import { writeTextFileAtomic } from "./atomic-write.ts"; -import { readFile } from "node:fs/promises"; +import { mkdir, readFile } from "node:fs/promises"; import { isNotFound } from "./fs.ts"; export const MAX_PARITY_LEVEL = 6; @@ -174,5 +174,7 @@ export const loadAppConfig = async (): Promise => { */ export const saveAppConfig = async (config: AppConfig): Promise => { const configPath = resolveFromBase("config.json"); + // On the first start the data directory is not there yet; made as the log directory is + await mkdir(dirname(configPath), { recursive: true }); await writeTextFileAtomic(configPath, JSON.stringify(config, null, 2)); }; diff --git a/e2e/harness/fixtures.ts b/e2e/harness/fixtures.ts index 13b2ee0..70bc865 100644 --- a/e2e/harness/fixtures.ts +++ b/e2e/harness/fixtures.ts @@ -49,7 +49,13 @@ export const test = base.extend<{ app: App }>({ ) }, expectFinished: async (label) => { - await expect(page.getByText(`"${label}" finished successfully`)).toBeVisible({ timeout: 90_000 }) + const toast = page.locator('[data-sonner-toast]').filter({ hasText: `"${label}" finished successfully` }) + await expect(toast).toBeVisible({ timeout: 90_000 }) + // Each job gets its own toast, they stack for 5 s (Feedback.tsx): closed, so the toast + // of the next job of the same command is the only one the next call finds. Dispatched, + // as an open dialog's overlay (a wizard's) lies over the toasts + await toast.getByRole('button', { name: 'Close toast' }).dispatchEvent('click') + await expect(toast).toHaveCount(0) await waitForIdle() }, waitForIdle, diff --git a/e2e/tests/schedules.spec.ts b/e2e/tests/schedules.spec.ts index c77a370..ac9986f 100644 --- a/e2e/tests/schedules.spec.ts +++ b/e2e/tests/schedules.spec.ts @@ -122,7 +122,10 @@ test('a disk that is not mounted pauses the scheduled sync', async ({ page, app // Back in place, nothing changed since the last sync: parity still has the files of d1 await array.remount('d1') expect(await array.snapraid('diff')).toMatch(/No differences/) + // The dashboard shows the status it kept (persisted, fresh for 10 s) without reading it again: + // the disk came back behind the app's back, so read it again as a user would await page.goto('/') + await page.getByRole('button', { name: 'Refresh', exact: true }).click() await expectHealth(page, 'All good') })