cameras/src/lib/first-run.test.ts
Michael Mainguy 2667967cf0 Say what a scan is doing, and remember where MediaMTX went (0.1.3)
- A scan no longer looks hung: the button counts seconds against the
  chosen timeout, the page says in words that it is listening for
  cameras and asking every address in turn, the refresh afterwards has
  its own state, and the result reports how many cameras were found, how
  long it took and how many addresses were checked. scanNetwork() returns
  that report; discoverCameras() still exists on top of it.
- The config pointer now remembers the binary folder beside the data
  folder, so install-video and start can't disagree about where MediaMTX
  lives, whichever directory each is run from.

Checked through an installed tarball against the real network: 2 cameras
found, 4,093 addresses probed, 3.0 s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 20:00:30 -05:00

238 lines
9.5 KiB
TypeScript

import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
applyEnvironment,
rememberBinDir,
serverEnvironment,
configFile,
ensureSecrets,
firstRun,
parseEnvFile,
readConfig,
writeConfig,
} from "./first-run";
let home: string;
let work: string;
let cwd: string;
beforeEach(async () => {
home = await mkdtemp(path.join(os.tmpdir(), "first-run-home-"));
work = await mkdtemp(path.join(os.tmpdir(), "first-run-data-"));
// Tests must never write into the repo, and the default folder is relative to cwd.
cwd = await mkdtemp(path.join(os.tmpdir(), "first-run-cwd-"));
vi.spyOn(process, "cwd").mockReturnValue(cwd);
});
afterEach(async () => {
for (const dir of [home, work, cwd]) await rm(dir, { recursive: true, force: true });
});
const logs: string[] = [];
const log = (line: string) => logs.push(line);
beforeEach(() => (logs.length = 0));
/** No terminal by default, and MediaMTX already installed, unless a test says otherwise. */
const run = (options: Parameters<typeof firstRun>[0] = {}) =>
firstRun({ env: {}, home, log, exists: async () => true, ...options });
describe("configFile", () => {
it("sits under the user's config directory, honouring XDG_CONFIG_HOME", () => {
expect(configFile({}, "/home/mm")).toBe("/home/mm/.config/onvif-dashboard/config.json");
expect(configFile({ XDG_CONFIG_HOME: "/cfg" }, "/home/mm")).toBe("/cfg/onvif-dashboard/config.json");
});
});
describe("readConfig / writeConfig", () => {
it("remembers the folder and reads it back", async () => {
await writeConfig({ dataDir: "/srv/clips" }, {}, home);
expect(await readConfig({}, home)).toEqual({ dataDir: "/srv/clips" });
});
it.each([["", "missing file"], ["{oops", "not JSON"], ["{}", "no folder"], ['{"dataDir":3}', "wrong type"]])(
"treats %j (%s) as nothing remembered",
async (text) => {
if (text) {
await mkdir(path.dirname(configFile({}, home)), { recursive: true });
await writeFile(configFile({}, home), text);
}
expect(await readConfig({}, home)).toEqual({});
},
);
});
describe("parseEnvFile", () => {
it("reads KEY=value lines and ignores comments and noise", () => {
expect(parseEnvFile("# comment\nA=1\n B = two \n\nnot a line\nlowercase=x\nC=")).toEqual({
A: "1",
B: "two",
C: "",
});
});
});
describe("ensureSecrets", () => {
it("creates an owner-only key file once, then leaves it alone", async () => {
const first = await ensureSecrets(work);
expect(first.created).toBe(true);
expect(first.values.CAMERA_CREDENTIALS_KEY).toMatch(/^[0-9a-f]{64}$/);
const file = path.join(work, "secrets.env");
expect((await stat(file)).mode & 0o777).toBe(0o600);
expect(await readFile(file, "utf8")).toContain("Keep this file private");
const again = await ensureSecrets(work);
expect(again.created).toBe(false);
expect(again.values.CAMERA_CREDENTIALS_KEY).toBe(first.values.CAMERA_CREDENTIALS_KEY);
});
it("keeps other values already in the file", async () => {
await writeFile(path.join(work, "secrets.env"), "OTHER=keep\n");
const { values } = await ensureSecrets(work);
expect(values).toMatchObject({ OTHER: "keep" });
expect(values.CAMERA_CREDENTIALS_KEY).toMatch(/^[0-9a-f]{64}$/);
});
});
describe("firstRun", () => {
it("asks where data should live, offering the working directory, and remembers the answer", async () => {
const ask = vi.fn(async () => work);
const result = await run({ ask });
expect(ask).toHaveBeenCalledWith("Where should this keep its data?", path.join(process.cwd(), ".data"));
expect(result).toMatchObject({ dataDir: work, source: "answered", secretCreated: true, videoReady: true });
expect(await readConfig({}, home)).toEqual({ dataDir: work });
expect(logs.join(" ")).toContain("Made a key for stored camera logins");
});
it("takes the offered default when the answer is empty", async () => {
expect((await run({ ask: async (_q, fallback) => fallback })).dataDir).toBe(path.join(cwd, ".data"));
});
it("resolves a relative answer against the directory it was started in", async () => {
const original = process.cwd;
process.cwd = () => cwd; // path.resolve reads this directly, so the spy isn't enough
try {
expect((await run({ ask: async () => "clips" })).dataDir).toBe(path.join(cwd, "clips"));
} finally {
process.cwd = original;
}
});
it("checks for MediaMTX on disk when not told otherwise", async () => {
const env = { CAMERAS_DATA_DIR: work, MEDIAMTX_BIN: path.join(work, "mediamtx") };
expect((await firstRun({ env, home, log })).videoReady).toBe(false);
await writeFile(env.MEDIAMTX_BIN, "#!/bin/sh\n");
expect((await firstRun({ env, home, log })).videoReady).toBe(true);
});
it("never blocks without a terminal: takes the default and says so", async () => {
const result = await run();
expect(result).toMatchObject({ source: "default", dataDir: path.join(process.cwd(), ".data") });
expect(logs.join(" ")).toContain("No terminal to ask");
expect(logs.join(" ")).toContain("CAMERAS_DATA_DIR");
});
it("uses the environment first, and doesn't remember what it was told", async () => {
const ask = vi.fn();
const result = await run({ env: { CAMERAS_DATA_DIR: work }, ask });
expect(result).toMatchObject({ dataDir: work, source: "environment" });
expect(ask).not.toHaveBeenCalled();
expect(await readConfig({}, home)).toEqual({});
});
it("uses the remembered folder on later runs, without asking again", async () => {
await writeConfig({ dataDir: work }, {}, home);
const ask = vi.fn();
const result = await run({ ask });
expect(result).toMatchObject({ dataDir: work, source: "remembered", secretCreated: true });
expect(ask).not.toHaveBeenCalled();
const again = await run({ ask });
expect(again.secretCreated).toBe(false); // nothing regenerated
});
it("says when live video needs installing", async () => {
const result = await run({ env: { CAMERAS_DATA_DIR: work }, exists: async () => false });
expect(result.videoReady).toBe(false);
expect(logs.join(" ")).toContain("install-video");
});
it("creates the data folder owner-only", async () => {
const dir = path.join(work, "nested", "data");
await run({ env: { CAMERAS_DATA_DIR: dir } });
expect((await stat(dir)).mode & 0o777).toBe(0o700);
});
});
describe("applyEnvironment", () => {
it("fills in the remembered folder and the stored key for this run", async () => {
await writeConfig({ dataDir: work }, {}, home);
await ensureSecrets(work);
const env: Record<string, string | undefined> = {};
await applyEnvironment(env, home);
expect(env.CAMERAS_DATA_DIR).toBe(work);
expect(env.CAMERA_CREDENTIALS_KEY).toMatch(/^[0-9a-f]{64}$/);
});
it("never overrides what the environment already says", async () => {
await writeConfig({ dataDir: work }, {}, home);
await ensureSecrets(work);
const env = { CAMERAS_DATA_DIR: "/elsewhere", CAMERA_CREDENTIALS_KEY: "mine" };
await applyEnvironment(env, home);
expect(env).toEqual({ CAMERAS_DATA_DIR: "/elsewhere", CAMERA_CREDENTIALS_KEY: "mine" });
});
it("does nothing when there is neither a pointer nor a key file", async () => {
const env: Record<string, string | undefined> = {};
await applyEnvironment(env, home);
expect(env.CAMERA_CREDENTIALS_KEY).toBeUndefined();
});
});
describe("serverEnvironment", () => {
it("pins the data and binary folders before the server starts elsewhere", async () => {
await writeConfig({ dataDir: work }, {}, home);
await ensureSecrets(work);
const env: Record<string, string | undefined> = {};
await serverEnvironment(env, home);
expect(env.CAMERAS_DATA_DIR).toBe(work);
// Resolved from where the command was typed, not from wherever the server will run.
expect(env.CAMERAS_BIN_DIR).toBe(path.join(cwd, "bin"));
expect(env.CAMERA_CREDENTIALS_KEY).toMatch(/^[0-9a-f]{64}$/);
});
it("leaves anything the environment already set alone", async () => {
const env = { CAMERAS_DATA_DIR: "/srv/data", CAMERAS_BIN_DIR: "/opt/bin" };
await serverEnvironment(env, home);
expect(env).toMatchObject({ CAMERAS_DATA_DIR: "/srv/data", CAMERAS_BIN_DIR: "/opt/bin" });
});
});
describe("rememberBinDir", () => {
it("remembers where helper binaries went, so another directory still finds them", async () => {
const env: Record<string, string | undefined> = {};
expect(await rememberBinDir(env, home)).toBe(path.join(cwd, "bin"));
expect(await readConfig({}, home)).toMatchObject({ binDir: path.join(cwd, "bin") });
// A later command, run from somewhere else entirely, is told the same folder.
const later: Record<string, string | undefined> = {};
await applyEnvironment(later, home);
expect(later.CAMERAS_BIN_DIR).toBe(path.join(cwd, "bin"));
});
it("keeps the data folder it was told earlier", async () => {
await writeConfig({ dataDir: work }, {}, home);
await rememberBinDir({ CAMERAS_BIN_DIR: "/opt/bin" }, home);
expect(await readConfig({}, home)).toEqual({ dataDir: work, binDir: "/opt/bin" });
});
it("prefers the environment, then what was remembered", async () => {
await writeConfig({ binDir: "/remembered" }, {}, home);
expect(await rememberBinDir({ CAMERAS_BIN_DIR: "/from-env" }, home)).toBe("/from-env");
expect(await rememberBinDir({}, home)).toBe("/from-env");
});
});