diff --git a/web/e2e/specs/settings/camera-wizard-step1.spec.ts b/web/e2e/specs/settings/camera-wizard-step1.spec.ts new file mode 100644 index 0000000000..a1d886a867 --- /dev/null +++ b/web/e2e/specs/settings/camera-wizard-step1.spec.ts @@ -0,0 +1,77 @@ +/** + * Add-camera wizard Step 1 validation -- MEDIUM tier. + * + * Continue validates every field, so an invalid ONVIF port or a non-RTSP + * custom URL must show its translated message and keep the wizard on Step 1. + */ + +import { test, expect } from "../../fixtures/frigate-test"; +import type { Page } from "@playwright/test"; + +const PORT_ERROR = "ONVIF port must be a whole number from 1 to 65535"; + +async function openWizard(page: Page) { + await page.getByRole("button", { name: /Add New Camera/i }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await dialog.getByPlaceholder(/front_door/i).fill("step1_camera"); + return dialog; +} + +test.describe("Camera wizard step 1 validation @medium @mobile", () => { + test.beforeEach(async ({ frigateApp }) => { + // not in the default mock; unmocked it 500s and trips the error collector + await frigateApp.page.route("**/api/config/raw_paths", (route) => + route.fulfill({ json: {} }), + ); + await frigateApp.page.route("**/api/onvif/probe**", (route) => + route.fulfill({ json: { success: false } }), + ); + await frigateApp.goto("/settings?page=cameraManagement"); + await expect( + frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }), + ).toBeVisible(); + }); + + test("an invalid ONVIF port blocks Continue with a translated message", async ({ + frigateApp, + }) => { + const dialog = await openWizard(frigateApp.page); + await dialog.getByPlaceholder("192.168.1.100").fill("192.168.1.100"); + const port = dialog.getByPlaceholder("80", { exact: true }); + const next = dialog.getByRole("button", { name: /^Continue$/i }); + + for (const value of ["abc", "0", "70000", "80.5"]) { + await port.fill(value); + await next.click(); + await expect(dialog.getByText(PORT_ERROR)).toBeVisible(); + await expect(port).toBeVisible(); + } + + await port.fill("8080"); + await next.click(); + await expect(port).toBeHidden(); + await expect(dialog.getByText(PORT_ERROR)).toBeHidden(); + }); + + test("a custom URL must use RTSP", async ({ frigateApp }) => { + const dialog = await openWizard(frigateApp.page); + await dialog.getByText("Manual selection", { exact: true }).click(); + await dialog.getByRole("combobox").click(); + await frigateApp.page.getByRole("option", { name: "Other" }).click(); + const url = dialog.getByPlaceholder( + "rtsp://username:password@host:port/path", + ); + const next = dialog.getByRole("button", { name: /^Continue$/i }); + + await url.fill("http://192.168.1.100/stream"); + await next.click(); + await expect( + dialog.getByText(/Custom URLs must begin with "rtsp:\/\/"/), + ).toBeVisible(); + + await url.fill("rtsp://192.168.1.100/stream"); + await next.click(); + await expect(url).toBeHidden(); + }); +}); diff --git a/web/e2e/specs/settings/zones.spec.ts b/web/e2e/specs/settings/zones.spec.ts index 64eaab094f..13bfe786e3 100644 --- a/web/e2e/specs/settings/zones.spec.ts +++ b/web/e2e/specs/settings/zones.spec.ts @@ -248,4 +248,37 @@ test.describe("zone rename and delete @medium @mobile", () => { }, }); }); + + test("inertia below 1 blocks saving and a valid value saves as a number", async ({ + frigateApp, + }) => { + const requests = await installRoutes(frigateApp.page); + await frigateApp.goto(SETTINGS_URL); + + await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit"); + const inertia = frigateApp.page.getByRole("textbox", { + name: "Inertia", + exact: true, + }); + const save = frigateApp.page.getByRole("button", { name: "Save" }); + + await inertia.fill("0"); + await expect( + frigateApp.page.getByText("Inertia must be above 0."), + ).toBeVisible(); + await expect(save).toBeDisabled(); + + await inertia.fill("5"); + await expect( + frigateApp.page.getByText("Inertia must be above 0."), + ).toBeHidden(); + await save.click(); + + await expect.poll(() => requests.length).toBe(1); + expect(requests[0].body).toMatchObject({ + config_data: { + cameras: { front_door: { zones: { driveway: { inertia: 5 } } } }, + }, + }); + }); }); diff --git a/web/package-lock.json b/web/package-lock.json index 405fcf5ba3..32dfc51f40 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -83,7 +83,7 @@ "vaul": "^1.1.2", "virtua": "^0.53.6", "vite-plugin-monaco-editor": "^1.1.0", - "zod": "^3.25.76" + "zod": "^4.6.7" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -3131,9 +3131,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3151,9 +3148,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3171,9 +3165,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3191,9 +3182,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3211,9 +3199,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3231,9 +3216,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5115,16 +5097,6 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, - "node_modules/eslint-plugin-react-hooks/node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", @@ -10655,9 +10627,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/web/package.json b/web/package.json index 4af571bc19..d2ee5e0c17 100644 --- a/web/package.json +++ b/web/package.json @@ -95,7 +95,7 @@ "vaul": "^1.1.2", "virtua": "^0.53.6", "vite-plugin-monaco-editor": "^1.1.0", - "zod": "^3.25.76" + "zod": "^4.6.7" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 6025b89136..267c88f49f 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -367,7 +367,8 @@ "nameLength": "Camera name must be 64 characters or less", "invalidCharacters": "Camera name contains invalid characters", "nameExists": "Camera name already exists", - "customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\" or \"rtsps://\". Manual configuration is required for non-RTSP camera streams." + "customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\" or \"rtsps://\". Manual configuration is required for non-RTSP camera streams.", + "onvifPortInvalid": "ONVIF port must be a whole number from 1 to 65535" } }, "step2": { diff --git a/web/src/components/classification/ClassificationModelEditDialog.tsx b/web/src/components/classification/ClassificationModelEditDialog.tsx index 0e228b31ad..c769e49a96 100644 --- a/web/src/components/classification/ClassificationModelEditDialog.tsx +++ b/web/src/components/classification/ClassificationModelEditDialog.tsx @@ -114,7 +114,7 @@ export default function ClassificationModelEditDialog({ const sharedFields = { enabled: z.boolean(), saveAttempts: z.coerce - .number({ message: t("edit.errors.saveAttemptsInvalid") }) + .number({ message: t("edit.errors.saveAttemptsInvalid") }) .int(t("edit.errors.saveAttemptsInvalid")) .min(0, t("edit.errors.saveAttemptsInvalid")), }; @@ -136,7 +136,7 @@ export default function ClassificationModelEditDialog({ (val) => val === "" || val === null || val === undefined ? undefined : val, z.coerce - .number({ message: t("edit.errors.intervalInvalid") }) + .number({ message: t("edit.errors.intervalInvalid") }) .int(t("edit.errors.intervalInvalid")) .positive(t("edit.errors.intervalInvalid")) .optional(), @@ -171,7 +171,6 @@ export default function ClassificationModelEditDialog({ resolver: zodResolver( formSchema as z.ZodType< ObjectFormData | StateFormData, - z.ZodTypeDef, ObjectFormData | StateFormData >, ), diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 43541ad11b..d6ce41f5e8 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -200,13 +200,13 @@ export default function ZoneEditPane({ ), enabled: z.boolean().default(true), inertia: z.coerce - .number() + .number() .min(1, { message: t("masksAndZones.form.inertia.error.mustBeAboveZero"), }) .or(z.literal("")), loitering_time: z.coerce - .number() + .number() .min(0, { message: t( "masksAndZones.form.loiteringTime.error.mustBeGreaterOrEqualZero", @@ -222,35 +222,35 @@ export default function ZoneEditPane({ review_detections: z.boolean().default(false).optional(), speedEstimation: z.boolean().default(false), lineA: z.coerce - .number() + .number() .min(0.1, { message: t("masksAndZones.form.distance.error.text"), }) .optional() .or(z.literal("")), lineB: z.coerce - .number() + .number() .min(0.1, { message: t("masksAndZones.form.distance.error.text"), }) .optional() .or(z.literal("")), lineC: z.coerce - .number() + .number() .min(0.1, { message: t("masksAndZones.form.distance.error.text"), }) .optional() .or(z.literal("")), lineD: z.coerce - .number() + .number() .min(0.1, { message: t("masksAndZones.form.distance.error.text"), }) .optional() .or(z.literal("")), speed_threshold: z.coerce - .number() + .number() .min(0.1, { message: t("masksAndZones.form.speed.error.mustBeGreaterOrEqualTo"), }) diff --git a/web/src/components/settings/wizard/Step1NameCamera.tsx b/web/src/components/settings/wizard/Step1NameCamera.tsx index 98c15bcbab..50a6e475a7 100644 --- a/web/src/components/settings/wizard/Step1NameCamera.tsx +++ b/web/src/components/settings/wizard/Step1NameCamera.tsx @@ -81,7 +81,14 @@ export default function Step1NameCamera({ username: z.string().optional(), password: z.string().optional(), brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(), - onvifPort: z.coerce.number().int().min(1).max(65535).optional(), + onvifPort: z.coerce + .number({ + message: t("cameraWizard.step1.errors.onvifPortInvalid"), + }) + .int(t("cameraWizard.step1.errors.onvifPortInvalid")) + .min(1, t("cameraWizard.step1.errors.onvifPortInvalid")) + .max(65535, t("cameraWizard.step1.errors.onvifPortInvalid")) + .optional(), useDigestAuth: z.boolean().optional(), customUrl: z .string()