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.
*
* 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";
@@ -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();
// 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 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();
}
+2 -10
View File
@@ -155,7 +155,7 @@ test.describe("setup wizard account @high @mobile", () => {
frigateApp,
page,
}) => {
const addCamera = await installFirstRun(frigateApp, page);
await installFirstRun(frigateApp, page);
await captureUserCalls(page);
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 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 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 page.getByRole("button", { name: "Skip" }).click();
// every remaining step is passed without writing config: Skip on the
// 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,
page,
}) => {
const addCamera = await installFirstRun(frigateApp, page, {
await installFirstRun(frigateApp, page, {
hwaccel: {
recommended: "vaapi",
available: [
@@ -79,7 +79,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
const saves = await captureSaves(page);
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
await expect(
@@ -124,13 +124,13 @@ test.describe("setup wizard hardware @high @mobile", () => {
frigateApp,
page,
}) => {
const addCamera = await installFirstRun(frigateApp, page, {
await installFirstRun(frigateApp, page, {
hardware: NVIDIA_HARDWARE,
});
const saves = await captureSaves(page);
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
await gotoDetectorStep(page, addCamera);
await gotoDetectorStep(page);
await expect(
page.getByRole("radio", { name: /NVIDIA GeForce RTX 3060/ }),
@@ -165,7 +165,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
frigateApp,
page,
}) => {
const addCamera = await installFirstRun(frigateApp, page, {
await installFirstRun(frigateApp, page, {
hardware: NVIDIA_HARDWARE,
hwaccel: {
recommended: "nvidia",
@@ -175,7 +175,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
await captureSaves(page);
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
await gotoDetectorStep(page, addCamera);
await gotoDetectorStep(page);
await page
.getByRole("button", { name: "Continue without detection" })
.click();
@@ -201,7 +201,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
frigateApp,
page,
}) => {
const addCamera = await installFirstRun(frigateApp, page, {
await installFirstRun(frigateApp, page, {
hwaccel: {
recommended: "jetson",
available: [
@@ -218,7 +218,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
const saves = await captureSaves(page);
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
await gotoDetectorStep(page, addCamera);
await gotoDetectorStep(page);
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
@@ -238,11 +238,11 @@ test.describe("setup wizard hardware @high @mobile", () => {
frigateApp,
page,
}) => {
const addCamera = await installFirstRun(frigateApp, page);
await installFirstRun(frigateApp, page);
const saves = await captureSaves(page);
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
await gotoDetectorStep(page, addCamera);
await gotoDetectorStep(page);
await page.getByRole("button", { name: "Next" }).click();
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 Statusbar from "./components/Statusbar";
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 { cn } from "./lib/utils";
import { isPWA } from "./utils/isPWA";
@@ -15,6 +15,8 @@ import useSWR from "swr";
import { FrigateConfig } from "./types/frigateConfig";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { isRedirectingToLogin } from "@/api/auth-redirect";
import { AuthContext } from "@/context/auth-context";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { isSetupDismissed } from "@/utils/setupWizard";
const Live = lazy(() => import("@/pages/Live"));
@@ -57,14 +59,20 @@ function DefaultAppView() {
// decided once per load: adding the first camera part way through the
// wizard must not pull the wizard out from under the user
const [showWizard, setShowWizard] = useState<boolean>();
const { auth } = useContext(AuthContext);
const isAdmin = useIsAdmin();
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(
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
// 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 { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { FaCircleCheck } from "react-icons/fa6";
type SetupCameraProps = {
@@ -19,30 +17,18 @@ export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
const [showWizard, setShowWizard] = useState(false);
const [addedCameras, setAddedCameras] = useState<string[]>([]);
const [detectCodecs, setDetectCodecs] = useState<Record<string, string>>({});
const { mutate: mutateConfig } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const handleClose = useCallback(() => {
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(
({ name, detectCodec }: { name: string; detectCodec?: string }) => {
setAddedCameras((previous) =>
previous.includes(name) ? previous : [...previous, name],
);
if (detectCodec) {
setDetectCodecs((previous) => ({ ...previous, [name]: detectCodec }));
}
@@ -51,9 +37,13 @@ export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
);
const handleNext = useCallback(() => {
onNext(addedCameras.length > 0 ? addedCameras : undefined, detectCodecs);
onNext(addedCameras, detectCodecs);
}, [onNext, addedCameras, detectCodecs]);
const handleSkip = useCallback(() => {
onNext();
}, [onNext]);
return (
<>
<div className="flex flex-col gap-4 py-4">
@@ -97,10 +87,14 @@ export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
{t("setupWizard.actions.back")}
</Button>
<div className="flex flex-1 justify-end gap-3">
{addedCameras.length > 0 && (
{addedCameras.length > 0 ? (
<Button type="button" variant="select" onClick={handleNext}>
{t("setupWizard.actions.next")}
</Button>
) : (
<Button type="button" variant="outline" onClick={handleSkip}>
{t("setupWizard.actions.skip")}
</Button>
)}
</div>
</div>