From 998815c2ddefdd52a2373bc4b1e869fa9b98fd22 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Fri, 8 May 2026 07:13:20 +0800 Subject: [PATCH] fix: allow omitted optional rpc schema fields with zod 4.4 (#1541) --- mobile/package.json | 2 +- mobile/pnpm-lock.yaml | 10 +-- package.json | 2 +- pnpm-lock.yaml | 26 +++---- .../runtime/rpc/methods/browser-schemas.ts | 12 ++-- src/main/runtime/rpc/methods/terminal.ts | 2 +- src/main/runtime/rpc/methods/worktree.ts | 2 +- src/main/runtime/rpc/schemas.test.ts | 67 +++++++++++++++++++ src/main/runtime/rpc/schemas.ts | 10 +-- 9 files changed, 100 insertions(+), 33 deletions(-) create mode 100644 src/main/runtime/rpc/schemas.test.ts diff --git a/mobile/package.json b/mobile/package.json index 26a7cb7e7ae..ebdbcf66606 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -38,7 +38,7 @@ "react-native-worklets": "^0.7.4", "tweetnacl": "^1.0.3", "ws": "^8.20.0", - "zod": "~4.3.6", + "zod": "~4.4.3", "zustand": "^5.0.13" }, "devDependencies": { diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 78c47a28f54..214346b470b 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -87,8 +87,8 @@ importers: specifier: ^8.20.0 version: 8.20.0 zod: - specifier: ~4.3.6 - version: 4.3.6 + specifier: ~4.4.3 + version: 4.4.3 zustand: specifier: ^5.0.13 version: 5.0.13(@types/react@19.2.14)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) @@ -5637,8 +5637,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} zustand@5.0.13: resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} @@ -12492,7 +12492,7 @@ snapshots: zod@3.25.76: {} - zod@4.3.6: {} + zod@4.4.3: {} zustand@5.0.13(@types/react@19.2.14)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)): optionalDependencies: diff --git a/package.json b/package.json index 028c67edc30..5c1ec79f811 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "tw-animate-css": "^1.4.0", "tweetnacl": "^1.0.3", "ws": "^8.20.0", - "zod": "~4.3.6", + "zod": "~4.4.3", "zustand": "^5.0.13" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9aca8257f64..f1347ae90df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,8 +227,8 @@ importers: specifier: ^8.20.0 version: 8.20.0 zod: - specifier: ~4.3.6 - version: 4.3.6 + specifier: ~4.4.3 + version: 4.4.3 zustand: specifier: ^5.0.13 version: 5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) @@ -244,7 +244,7 @@ importers: version: 1.59.1 '@stablyai/playwright-test': specifier: ^2.1.14 - version: 2.1.14(@playwright/test@1.59.1)(zod@4.3.6) + version: 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) '@tailwindcss/vite': specifier: ^4.2.4 version: 4.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) @@ -6376,8 +6376,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} zustand@5.0.13: resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} @@ -8224,27 +8224,27 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@stablyai/playwright-base@2.1.14(@playwright/test@1.59.1)(zod@4.3.6)': + '@stablyai/playwright-base@2.1.14(@playwright/test@1.59.1)(zod@4.4.3)': dependencies: '@playwright/test': 1.59.1 jpeg-js: 0.4.4 p-retry: 4.6.2 pngjs: 7.0.0 optionalDependencies: - zod: 4.3.6 + zod: 4.4.3 - '@stablyai/playwright-test@2.1.14(@playwright/test@1.59.1)(zod@4.3.6)': + '@stablyai/playwright-test@2.1.14(@playwright/test@1.59.1)(zod@4.4.3)': dependencies: '@playwright/test': 1.59.1 - '@stablyai/playwright': 2.1.14(@playwright/test@1.59.1)(zod@4.3.6) - '@stablyai/playwright-base': 2.1.14(@playwright/test@1.59.1)(zod@4.3.6) + '@stablyai/playwright': 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) + '@stablyai/playwright-base': 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) transitivePeerDependencies: - zod - '@stablyai/playwright@2.1.14(@playwright/test@1.59.1)(zod@4.3.6)': + '@stablyai/playwright@2.1.14(@playwright/test@1.59.1)(zod@4.4.3)': dependencies: '@playwright/test': 1.59.1 - '@stablyai/playwright-base': 2.1.14(@playwright/test@1.59.1)(zod@4.3.6) + '@stablyai/playwright-base': 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) transitivePeerDependencies: - zod @@ -12882,7 +12882,7 @@ snapshots: zod@3.25.76: {} - zod@4.3.6: {} + zod@4.4.3: {} zustand@5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)): optionalDependencies: diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index e3ff203a4f4..0a216c1d9e0 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -45,7 +45,7 @@ export const Scroll = BrowserTarget.extend({ amount: z .unknown() .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined)) - .pipe(z.number().optional()) + .pipe(z.union([z.number(), z.undefined()])) .optional() }) @@ -53,7 +53,7 @@ export const Screenshot = BrowserTarget.extend({ format: z .unknown() .transform((v) => (v === 'png' || v === 'jpeg' ? v : undefined)) - .pipe(z.enum(['png', 'jpeg']).optional()) + .pipe(z.union([z.enum(['png', 'jpeg']), z.undefined()])) .optional() }) @@ -86,7 +86,7 @@ export const TabSwitch = BrowserTarget.extend({ index: z .unknown() .transform((v) => (typeof v === 'number' ? v : undefined)) - .pipe(z.number().optional()) + .pipe(z.union([z.number(), z.undefined()])) .optional(), focus: z.boolean().optional() }).refine( @@ -118,7 +118,7 @@ export const TabClose = z.object({ index: z .unknown() .transform((v) => (typeof v === 'number' ? v : undefined)) - .pipe(z.number().optional()) + .pipe(z.union([z.number(), z.undefined()])) .optional(), page: OptionalString, worktree: OptionalString @@ -161,7 +161,7 @@ export const Wait = BrowserTarget.extend({ timeout: z .unknown() .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined)) - .pipe(z.number().optional()) + .pipe(z.union([z.number(), z.undefined()])) .optional(), text: OptionalPlainString, url: OptionalPlainString, @@ -275,7 +275,7 @@ export const InterceptEnable = BrowserTarget.extend({ patterns: z .unknown() .transform((v) => (Array.isArray(v) ? (v as string[]) : undefined)) - .pipe(z.array(z.string()).optional()) + .pipe(z.union([z.array(z.string()), z.undefined()])) .optional() }) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index f6e67d15455..088faee9048 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -90,7 +90,7 @@ const TerminalSplit = TerminalHandle.extend({ direction: z .unknown() .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined)) - .pipe(z.enum(['vertical', 'horizontal']).optional()) + .pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()])) .optional(), command: OptionalString }) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 08d0cd53910..b9f507c1293 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -38,7 +38,7 @@ const WorktreeCreate = z.object({ .transform((v) => typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined ) - .pipe(z.enum(['run', 'skip', 'inherit']).optional()) + .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) .optional(), // Why: mobile clients pass a startup command (e.g. 'claude') so the first // terminal pane launches the selected agent instead of an idle shell. diff --git a/src/main/runtime/rpc/schemas.test.ts b/src/main/runtime/rpc/schemas.test.ts new file mode 100644 index 00000000000..e68b3a572dd --- /dev/null +++ b/src/main/runtime/rpc/schemas.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { z, type ZodType } from 'zod' +import { + BrowserTarget, + OptionalBoolean, + OptionalFiniteNumber, + OptionalPlainString, + OptionalPositiveInt, + OptionalString +} from './schemas' +import { + InterceptEnable, + Screenshot, + Scroll, + TabClose, + TabSwitch, + Wait +} from './methods/browser-schemas' +import { TERMINAL_METHODS } from './methods/terminal' +import { WORKTREE_METHODS } from './methods/worktree' + +function expectParses(schema: ZodType, value: unknown): void { + const result = schema.safeParse(value) + expect(result.success, result.success ? undefined : JSON.stringify(result.error.issues)).toBe( + true + ) +} + +function methodParams( + methods: readonly { name: string; params: ZodType | null }[], + name: string +): ZodType { + const method = methods.find((candidate) => candidate.name === name) + if (!method?.params) { + throw new Error(`missing test method schema: ${name}`) + } + return method.params +} + +describe('RPC optional pipe schemas', () => { + it('accepts omitted shared optional helper fields', () => { + const schema = z.object({ + finite: OptionalFiniteNumber, + positive: OptionalPositiveInt, + string: OptionalString, + plain: OptionalPlainString, + boolean: OptionalBoolean + }) + + expectParses(schema, {}) + }) + + it('accepts omitted browser optional fields while required fields are present', () => { + expectParses(Scroll, { direction: 'down' }) + expectParses(Screenshot, {}) + expectParses(TabSwitch, { page: 'page-1' }) + expectParses(TabClose, {}) + expectParses(Wait, {}) + expectParses(InterceptEnable, {}) + expectParses(BrowserTarget, {}) + }) + + it('accepts omitted terminal and worktree optional fields while required fields are present', () => { + expectParses(methodParams(TERMINAL_METHODS, 'terminal.split'), { terminal: 'terminal-1' }) + expectParses(methodParams(WORKTREE_METHODS, 'worktree.create'), { repo: 'repo-1' }) + }) +}) diff --git a/src/main/runtime/rpc/schemas.ts b/src/main/runtime/rpc/schemas.ts index ab3f2d15668..eaccbaed070 100644 --- a/src/main/runtime/rpc/schemas.ts +++ b/src/main/runtime/rpc/schemas.ts @@ -13,7 +13,7 @@ import { z } from 'zod' export const OptionalFiniteNumber = z .unknown() .transform((value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)) - .pipe(z.number().optional()) + .pipe(z.union([z.number(), z.undefined()])) .optional() export const OptionalPositiveInt = z @@ -21,25 +21,25 @@ export const OptionalPositiveInt = z .transform((value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined ) - .pipe(z.number().optional()) + .pipe(z.union([z.number(), z.undefined()])) .optional() export const OptionalString = z .unknown() .transform((value) => (typeof value === 'string' && value.length > 0 ? value : undefined)) - .pipe(z.string().optional()) + .pipe(z.union([z.string(), z.undefined()])) .optional() export const OptionalPlainString = z .unknown() .transform((value) => (typeof value === 'string' ? value : undefined)) - .pipe(z.string().optional()) + .pipe(z.union([z.string(), z.undefined()])) .optional() export const OptionalBoolean = z .unknown() .transform((value) => (typeof value === 'boolean' ? value : undefined)) - .pipe(z.boolean().optional()) + .pipe(z.union([z.boolean(), z.undefined()])) .optional() // Why: runtime handlers accept `linkedIssue: number | null | undefined` with