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.
This commit is contained in:
Josh Hawkins
2026-10-25 12:19:13 -05:00
parent 9b12e3d78e
commit c4bb9b9401
8 changed files with 134 additions and 45 deletions
@@ -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();
});
});
+33
View File
@@ -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 } } } },
},
});
});
}); });
+4 -32
View File
@@ -83,7 +83,7 @@
"vaul": "^1.1.2", "vaul": "^1.1.2",
"virtua": "^0.53.6", "virtua": "^0.53.6",
"vite-plugin-monaco-editor": "^1.1.0", "vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.25.76" "zod": "^4.6.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@@ -3131,9 +3131,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3151,9 +3148,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3171,9 +3165,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3191,9 +3182,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3211,9 +3199,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3231,9 +3216,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "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" "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": { "node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
@@ -10655,9 +10627,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "4.6.7",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
+1 -1
View File
@@ -95,7 +95,7 @@
"vaul": "^1.1.2", "vaul": "^1.1.2",
"virtua": "^0.53.6", "virtua": "^0.53.6",
"vite-plugin-monaco-editor": "^1.1.0", "vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.25.76" "zod": "^4.6.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
+2 -1
View File
@@ -367,7 +367,8 @@
"nameLength": "Camera name must be 64 characters or less", "nameLength": "Camera name must be 64 characters or less",
"invalidCharacters": "Camera name contains invalid characters", "invalidCharacters": "Camera name contains invalid characters",
"nameExists": "Camera name already exists", "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": { "step2": {
@@ -114,7 +114,7 @@ export default function ClassificationModelEditDialog({
const sharedFields = { const sharedFields = {
enabled: z.boolean(), enabled: z.boolean(),
saveAttempts: z.coerce saveAttempts: z.coerce
.number({ message: t("edit.errors.saveAttemptsInvalid") }) .number<number>({ message: t("edit.errors.saveAttemptsInvalid") })
.int(t("edit.errors.saveAttemptsInvalid")) .int(t("edit.errors.saveAttemptsInvalid"))
.min(0, t("edit.errors.saveAttemptsInvalid")), .min(0, t("edit.errors.saveAttemptsInvalid")),
}; };
@@ -136,7 +136,7 @@ export default function ClassificationModelEditDialog({
(val) => (val) =>
val === "" || val === null || val === undefined ? undefined : val, val === "" || val === null || val === undefined ? undefined : val,
z.coerce z.coerce
.number({ message: t("edit.errors.intervalInvalid") }) .number<number>({ message: t("edit.errors.intervalInvalid") })
.int(t("edit.errors.intervalInvalid")) .int(t("edit.errors.intervalInvalid"))
.positive(t("edit.errors.intervalInvalid")) .positive(t("edit.errors.intervalInvalid"))
.optional(), .optional(),
@@ -171,7 +171,6 @@ export default function ClassificationModelEditDialog({
resolver: zodResolver( resolver: zodResolver(
formSchema as z.ZodType< formSchema as z.ZodType<
ObjectFormData | StateFormData, ObjectFormData | StateFormData,
z.ZodTypeDef,
ObjectFormData | StateFormData ObjectFormData | StateFormData
>, >,
), ),
+7 -7
View File
@@ -200,13 +200,13 @@ export default function ZoneEditPane({
), ),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
inertia: z.coerce inertia: z.coerce
.number() .number<number>()
.min(1, { .min(1, {
message: t("masksAndZones.form.inertia.error.mustBeAboveZero"), message: t("masksAndZones.form.inertia.error.mustBeAboveZero"),
}) })
.or(z.literal("")), .or(z.literal("")),
loitering_time: z.coerce loitering_time: z.coerce
.number() .number<number>()
.min(0, { .min(0, {
message: t( message: t(
"masksAndZones.form.loiteringTime.error.mustBeGreaterOrEqualZero", "masksAndZones.form.loiteringTime.error.mustBeGreaterOrEqualZero",
@@ -222,35 +222,35 @@ export default function ZoneEditPane({
review_detections: z.boolean().default(false).optional(), review_detections: z.boolean().default(false).optional(),
speedEstimation: z.boolean().default(false), speedEstimation: z.boolean().default(false),
lineA: z.coerce lineA: z.coerce
.number() .number<number>()
.min(0.1, { .min(0.1, {
message: t("masksAndZones.form.distance.error.text"), message: t("masksAndZones.form.distance.error.text"),
}) })
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
lineB: z.coerce lineB: z.coerce
.number() .number<number>()
.min(0.1, { .min(0.1, {
message: t("masksAndZones.form.distance.error.text"), message: t("masksAndZones.form.distance.error.text"),
}) })
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
lineC: z.coerce lineC: z.coerce
.number() .number<number>()
.min(0.1, { .min(0.1, {
message: t("masksAndZones.form.distance.error.text"), message: t("masksAndZones.form.distance.error.text"),
}) })
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
lineD: z.coerce lineD: z.coerce
.number() .number<number>()
.min(0.1, { .min(0.1, {
message: t("masksAndZones.form.distance.error.text"), message: t("masksAndZones.form.distance.error.text"),
}) })
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
speed_threshold: z.coerce speed_threshold: z.coerce
.number() .number<number>()
.min(0.1, { .min(0.1, {
message: t("masksAndZones.form.speed.error.mustBeGreaterOrEqualTo"), message: t("masksAndZones.form.speed.error.mustBeGreaterOrEqualTo"),
}) })
@@ -81,7 +81,14 @@ export default function Step1NameCamera({
username: z.string().optional(), username: z.string().optional(),
password: z.string().optional(), password: z.string().optional(),
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(), brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
onvifPort: z.coerce.number().int().min(1).max(65535).optional(), onvifPort: z.coerce
.number<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(), useDigestAuth: z.boolean().optional(),
customUrl: z customUrl: z
.string() .string()