This commit is contained in:
Josh Hawkins
2026-08-26 17:20:33 -05:00
parent df4e2bea8a
commit 7c73779075
5 changed files with 46 additions and 60 deletions
+5 -13
View File
@@ -2,7 +2,9 @@
* Shared setup-wizard e2e helpers. * Shared setup-wizard e2e helpers.
* *
* The wizard shows when config has no cameras, so a first run is mocked by * The wizard shows when config has no cameras, so a first run is mocked by
* serving a camera-less config until the returned callback is fired. * serving a camera-less config until the returned callback is fired. Firing
* it is only needed by tests that care what the rest of the app sees; the
* wizard itself tracks added cameras from the camera dialog's own callback.
*/ */
import type { Page } from "@playwright/test"; import type { Page } from "@playwright/test";
@@ -32,7 +34,7 @@ export async function installFirstRun(
}; };
} }
export async function gotoDetectorStep(page: Page, addCamera: () => void) { export async function gotoDetectorStep(page: Page) {
await page.getByRole("button", { name: "Get Started" }).click(); await page.getByRole("button", { name: "Get Started" }).click();
// the account step sits between welcome and camera whenever auth is on, // the account step sits between welcome and camera whenever auth is on,
@@ -43,17 +45,7 @@ export async function gotoDetectorStep(page: Page, addCamera: () => void) {
await page.getByRole("button", { name: "Skip" }).click(); await page.getByRole("button", { name: "Skip" }).click();
await expect(page.getByText("Add Your First Camera")).toBeVisible(); await expect(page.getByText("Add Your First Camera")).toBeVisible();
await page.getByRole("button", { name: "Skip" }).click();
// the camera step's Next only renders once its local addedCameras fills,
// which SetupCamera does by refetching config when the dialog closes, so
// opening and cancelling the dialog is what reveals Next
await page.getByRole("button", { name: "Add Camera" }).click();
addCamera();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("button", { name: "Next" })).toBeVisible({
timeout: 10_000,
});
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByText("Object Detection")).toBeVisible(); await expect(page.getByText("Object Detection")).toBeVisible();
} }
+2 -10
View File
@@ -155,7 +155,7 @@ test.describe("setup wizard account @high @mobile", () => {
frigateApp, frigateApp,
page, page,
}) => { }) => {
const addCamera = await installFirstRun(frigateApp, page); await installFirstRun(frigateApp, page);
await captureUserCalls(page); await captureUserCalls(page);
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
@@ -173,16 +173,8 @@ test.describe("setup wizard account @high @mobile", () => {
await expect(page.getByText("Password set")).toBeVisible(); await expect(page.getByText("Password set")).toBeVisible();
await page.getByRole("button", { name: "Next" }).click(); await page.getByRole("button", { name: "Next" }).click();
// the camera step has no Skip, so it is advanced the way the detector
// helper does, by opening and cancelling the add dialog
await expect(page.getByText("Add Your First Camera")).toBeVisible(); await expect(page.getByText("Add Your First Camera")).toBeVisible();
await page.getByRole("button", { name: "Add Camera" }).click(); await page.getByRole("button", { name: "Skip" }).click();
addCamera();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("button", { name: "Next" })).toBeVisible({
timeout: 10_000,
});
await page.getByRole("button", { name: "Next" }).click();
// every remaining step is passed without writing config: Skip on the // every remaining step is passed without writing config: Skip on the
// detector, then Auto on hwaccel, which has nothing to derive and so // detector, then Auto on hwaccel, which has nothing to derive and so
+10 -10
View File
@@ -61,7 +61,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
frigateApp, frigateApp,
page, page,
}) => { }) => {
const addCamera = await installFirstRun(frigateApp, page, { await installFirstRun(frigateApp, page, {
hwaccel: { hwaccel: {
recommended: "vaapi", recommended: "vaapi",
available: [ available: [
@@ -79,7 +79,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
const saves = await captureSaves(page); const saves = await captureSaves(page);
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
await gotoDetectorStep(page, addCamera); await gotoDetectorStep(page);
// the default hardware mock reports two Corals, an Intel GPU, and the CPU // the default hardware mock reports two Corals, an Intel GPU, and the CPU
await expect( await expect(
@@ -124,13 +124,13 @@ test.describe("setup wizard hardware @high @mobile", () => {
frigateApp, frigateApp,
page, page,
}) => { }) => {
const addCamera = await installFirstRun(frigateApp, page, { await installFirstRun(frigateApp, page, {
hardware: NVIDIA_HARDWARE, hardware: NVIDIA_HARDWARE,
}); });
const saves = await captureSaves(page); const saves = await captureSaves(page);
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
await gotoDetectorStep(page, addCamera); await gotoDetectorStep(page);
await expect( await expect(
page.getByRole("radio", { name: /NVIDIA GeForce RTX 3060/ }), page.getByRole("radio", { name: /NVIDIA GeForce RTX 3060/ }),
@@ -165,7 +165,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
frigateApp, frigateApp,
page, page,
}) => { }) => {
const addCamera = await installFirstRun(frigateApp, page, { await installFirstRun(frigateApp, page, {
hardware: NVIDIA_HARDWARE, hardware: NVIDIA_HARDWARE,
hwaccel: { hwaccel: {
recommended: "nvidia", recommended: "nvidia",
@@ -175,7 +175,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
await captureSaves(page); await captureSaves(page);
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
await gotoDetectorStep(page, addCamera); await gotoDetectorStep(page);
await page await page
.getByRole("button", { name: "Continue without detection" }) .getByRole("button", { name: "Continue without detection" })
.click(); .click();
@@ -201,7 +201,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
frigateApp, frigateApp,
page, page,
}) => { }) => {
const addCamera = await installFirstRun(frigateApp, page, { await installFirstRun(frigateApp, page, {
hwaccel: { hwaccel: {
recommended: "jetson", recommended: "jetson",
available: [ available: [
@@ -218,7 +218,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
const saves = await captureSaves(page); const saves = await captureSaves(page);
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
await gotoDetectorStep(page, addCamera); await gotoDetectorStep(page);
await page.getByRole("button", { name: "Next" }).click(); await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByText("Hardware Acceleration")).toBeVisible(); await expect(page.getByText("Hardware Acceleration")).toBeVisible();
@@ -238,11 +238,11 @@ test.describe("setup wizard hardware @high @mobile", () => {
frigateApp, frigateApp,
page, page,
}) => { }) => {
const addCamera = await installFirstRun(frigateApp, page); await installFirstRun(frigateApp, page);
const saves = await captureSaves(page); const saves = await captureSaves(page);
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate"); await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
await gotoDetectorStep(page, addCamera); await gotoDetectorStep(page);
await page.getByRole("button", { name: "Next" }).click(); await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByText("Hardware Acceleration")).toBeVisible(); await expect(page.getByText("Hardware Acceleration")).toBeVisible();
+12 -4
View File
@@ -6,7 +6,7 @@ import Sidebar from "@/components/navigation/Sidebar";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import Statusbar from "./components/Statusbar"; import Statusbar from "./components/Statusbar";
import Bottombar from "./components/navigation/Bottombar"; import Bottombar from "./components/navigation/Bottombar";
import { Suspense, lazy, useEffect, useState } from "react"; import { Suspense, lazy, useContext, useEffect, useState } from "react";
import { Redirect } from "./components/navigation/Redirect"; import { Redirect } from "./components/navigation/Redirect";
import { cn } from "./lib/utils"; import { cn } from "./lib/utils";
import { isPWA } from "./utils/isPWA"; import { isPWA } from "./utils/isPWA";
@@ -15,6 +15,8 @@ import useSWR from "swr";
import { FrigateConfig } from "./types/frigateConfig"; import { FrigateConfig } from "./types/frigateConfig";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { isRedirectingToLogin } from "@/api/auth-redirect"; import { isRedirectingToLogin } from "@/api/auth-redirect";
import { AuthContext } from "@/context/auth-context";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { isSetupDismissed } from "@/utils/setupWizard"; import { isSetupDismissed } from "@/utils/setupWizard";
const Live = lazy(() => import("@/pages/Live")); const Live = lazy(() => import("@/pages/Live"));
@@ -57,14 +59,20 @@ function DefaultAppView() {
// decided once per load: adding the first camera part way through the // decided once per load: adding the first camera part way through the
// wizard must not pull the wizard out from under the user // wizard must not pull the wizard out from under the user
const [showWizard, setShowWizard] = useState<boolean>(); const [showWizard, setShowWizard] = useState<boolean>();
const { auth } = useContext(AuthContext);
const isAdmin = useIsAdmin();
useEffect(() => { useEffect(() => {
if (config && showWizard === undefined) { // every step writes through admin only endpoints, and the role isn't
// known until the profile resolves
if (config && !auth.isLoading && showWizard === undefined) {
setShowWizard( setShowWizard(
Object.keys(config.cameras ?? {}).length === 0 && !isSetupDismissed(), isAdmin &&
Object.keys(config.cameras ?? {}).length === 0 &&
!isSetupDismissed(),
); );
} }
}, [config, showWizard]); }, [config, auth.isLoading, isAdmin, showWizard]);
// Compute required roles for main routes, ensuring we have config first // Compute required roles for main routes, ensuring we have config first
// to prevent race condition where custom roles are temporarily unavailable // to prevent race condition where custom roles are temporarily unavailable
+17 -23
View File
@@ -2,8 +2,6 @@ import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { FaCircleCheck } from "react-icons/fa6"; import { FaCircleCheck } from "react-icons/fa6";
type SetupCameraProps = { type SetupCameraProps = {
@@ -19,30 +17,18 @@ export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
const [showWizard, setShowWizard] = useState(false); const [showWizard, setShowWizard] = useState(false);
const [addedCameras, setAddedCameras] = useState<string[]>([]); const [addedCameras, setAddedCameras] = useState<string[]>([]);
const [detectCodecs, setDetectCodecs] = useState<Record<string, string>>({}); const [detectCodecs, setDetectCodecs] = useState<Record<string, string>>({});
const { mutate: mutateConfig } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setShowWizard(false); setShowWizard(false);
// the dialog's config save and go2rtc setup need a moment to land, and }, []);
// the SWR cache would serve the pre-save config
setTimeout(() => {
fetch(`${window.baseUrl || ""}api/config`)
.then((res) => res.json())
.then((freshConfig: FrigateConfig) => {
const cameraNames = Object.keys(freshConfig.cameras || {});
if (cameraNames.length > addedCameras.length) {
mutateConfig(freshConfig, { revalidate: false });
setAddedCameras(cameraNames);
}
})
.catch(() => {});
}, 1000);
}, [mutateConfig, addedCameras]);
// the dialog fires this once its config write has succeeded, which is the
// only reliable signal that a camera was added
const handleCameraAdded = useCallback( const handleCameraAdded = useCallback(
({ name, detectCodec }: { name: string; detectCodec?: string }) => { ({ name, detectCodec }: { name: string; detectCodec?: string }) => {
setAddedCameras((previous) =>
previous.includes(name) ? previous : [...previous, name],
);
if (detectCodec) { if (detectCodec) {
setDetectCodecs((previous) => ({ ...previous, [name]: detectCodec })); setDetectCodecs((previous) => ({ ...previous, [name]: detectCodec }));
} }
@@ -51,9 +37,13 @@ export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
); );
const handleNext = useCallback(() => { const handleNext = useCallback(() => {
onNext(addedCameras.length > 0 ? addedCameras : undefined, detectCodecs); onNext(addedCameras, detectCodecs);
}, [onNext, addedCameras, detectCodecs]); }, [onNext, addedCameras, detectCodecs]);
const handleSkip = useCallback(() => {
onNext();
}, [onNext]);
return ( return (
<> <>
<div className="flex flex-col gap-4 py-4"> <div className="flex flex-col gap-4 py-4">
@@ -97,10 +87,14 @@ export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
{t("setupWizard.actions.back")} {t("setupWizard.actions.back")}
</Button> </Button>
<div className="flex flex-1 justify-end gap-3"> <div className="flex flex-1 justify-end gap-3">
{addedCameras.length > 0 && ( {addedCameras.length > 0 ? (
<Button type="button" variant="select" onClick={handleNext}> <Button type="button" variant="select" onClick={handleNext}>
{t("setupWizard.actions.next")} {t("setupWizard.actions.next")}
</Button> </Button>
) : (
<Button type="button" variant="outline" onClick={handleSkip}>
{t("setupWizard.actions.skip")}
</Button>
)} )}
</div> </div>
</div> </div>