fix: allow omitted optional rpc schema fields with zod 4.4 (#1541)

This commit is contained in:
zerone0x
2026-05-07 16:13:20 -07:00
committed by GitHub
parent 343624d765
commit 998815c2dd
9 changed files with 100 additions and 33 deletions
+1 -1
View File
@@ -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": {
+5 -5
View File
@@ -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:
+1 -1
View File
@@ -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": {
+13 -13
View File
@@ -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:
@@ -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()
})
+1 -1
View File
@@ -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
})
+1 -1
View File
@@ -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.
+67
View File
@@ -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' })
})
})
+5 -5
View File
@@ -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