From 9fec1c76b5677e456b3cac6f8b40793f7e105553 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:44:39 -0500 Subject: [PATCH] Update zod to 4 (#24791) * fix races in ui settings import e2e tests confirmImport clicked any button named "Import", so when the file parse was slow it hit the Import Settings button before the dialog opened and the page never reloaded. The click is now scoped to the dialog. The legacy layouts test also wrote naturalAspectLayout after the view had already read it, so it reloads the view after the write. * update zod to 4.6.7 zod 4 types the input of z.coerce.number() as unknown, so the zone, camera wizard, and classification edit forms pass number to keep their field types. The ONVIF port error showed zod's built-in English text, which zod 4 rewords, so it now uses a translated message. New e2e tests cover zone inertia validation and camera wizard step 1 validation. --- .../settings/camera-wizard-step1.spec.ts | 77 +++++++++++++++++++ .../settings/ui-settings-transfer.spec.ts | 10 ++- web/e2e/specs/settings/zones.spec.ts | 33 ++++++++ web/package-lock.json | 36 +-------- web/package.json | 2 +- web/public/locales/en/views/settings.json | 3 +- .../ClassificationModelEditDialog.tsx | 5 +- web/src/components/settings/ZoneEditPane.tsx | 14 ++-- .../settings/wizard/Step1NameCamera.tsx | 9 ++- 9 files changed, 143 insertions(+), 46 deletions(-) create mode 100644 web/e2e/specs/settings/camera-wizard-step1.spec.ts 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/ui-settings-transfer.spec.ts b/web/e2e/specs/settings/ui-settings-transfer.spec.ts index 2716482a3c..f199e8cda7 100644 --- a/web/e2e/specs/settings/ui-settings-transfer.spec.ts +++ b/web/e2e/specs/settings/ui-settings-transfer.spec.ts @@ -121,9 +121,14 @@ async function confirmImport(page: Page) { // route change, so #pageRoot is already present and waiting for it // alone can race the navigation. Wait for the "load" event first so // the evaluate() calls below run against the post-reload document. + // scoped to the dialog: until it opens, a page-wide "Import" match is the + // Import Settings button, which only reopens the file chooser await Promise.all([ page.waitForEvent("load"), - page.getByRole("button", { name: "Import" }).click(), + page + .getByRole("dialog") + .getByRole("button", { name: "Import", exact: true }) + .click(), ]); await page.waitForSelector("#pageRoot"); } @@ -270,6 +275,9 @@ test.describe("UI settings import/export @medium", () => { // naturalAspectLayout preference must not override that. await frigateApp.goto("/settings?page=uiSettings"); await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true }); + // the view reads the setting once on mount, so load it again after the + // write + await frigateApp.goto("/settings?page=uiSettings"); await chooseImportFile( frigateApp.page, 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()