Skip to content

Commit 13a6b4d

Browse files
committed
Do not use normalized URLs by default. Add normalize flag to ZodURL. Closes #4906
1 parent aa80948 commit 13a6b4d

File tree

5 files changed

+205
-14
lines changed

5 files changed

+205
-14
lines changed

.cursor/rules/testing-guidelines.mdc

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
---
2+
description: Testing guidelines for the Zod codebase - when to use play.ts vs test suite, TypeScript requirements, and testing patterns
3+
globs: *.test.ts,*.test.tsx,play.ts
4+
---
5+
6+
# Zod Testing Guidelines
7+
8+
## Always Use TypeScript
9+
- **All tests must be written in TypeScript** - never use JavaScript
10+
- Test files should use `.test.ts` extension
11+
- Import types properly: `import type { ... }`
12+
13+
## When to Use play.ts vs Test Suite
14+
15+
### Use [play.ts](mdc:play.ts) for:
16+
- Quick experimentation and prototyping
17+
- Manual testing of new features during development
18+
- Debugging specific behaviors
19+
- One-off validation of edge cases
20+
- Temporary verification before writing formal tests
21+
22+
### Use Test Suite for:
23+
- **All permanent test cases** - features must have proper tests
24+
- Regression testing
25+
- API validation
26+
- Edge case coverage
27+
- Performance benchmarks
28+
29+
## Test Organization
30+
31+
### Test File Structure
32+
Tests are located in:
33+
- `packages/zod/src/v4/classic/tests/` - V4 classic API tests
34+
- `packages/zod/src/v4/core/tests/` - V4 core tests
35+
- `packages/zod/src/v3/tests/` - V3 compatibility tests
36+
37+
### Test File Naming
38+
- Use descriptive names: `string.test.ts`, `object.test.ts`, `url-validation.test.ts`
39+
- Group related functionality in the same test file
40+
- Follow existing patterns in [packages/zod/src/v4/classic/tests/string.test.ts](mdc:packages/zod/src/v4/classic/tests/string.test.ts)
41+
42+
## Test Writing Patterns
43+
44+
### Framework Usage
45+
- Use **Vitest** as the test framework
46+
- Import: `import { expect, test } from "vitest";`
47+
- Import Zod: `import * as z from "zod/v4";`
48+
49+
### Test Structure
50+
```typescript
51+
test("descriptive test name", () => {
52+
const schema = z.string().url();
53+
54+
// Test valid cases
55+
expect(schema.parse("https://example.com")).toBe("https://example.com");
56+
57+
// Test invalid cases
58+
expect(() => schema.parse("invalid")).toThrow();
59+
});
60+
```
61+
62+
### Testing Preferences
63+
- **Keep test suites concise** - use as few test cases as needed to cover functionality
64+
- **Don't skip tests** due to type issues - fix the types instead
65+
- **Don't oversimplify tests** - maintain adequate coverage
66+
- Test both success and failure cases
67+
- Use descriptive test names that explain the behavior being tested
68+
69+
### URL Testing Example
70+
When testing URL validation (as seen in recent work):
71+
```typescript
72+
test("url preserves original input", () => {
73+
const url = z.string().url();
74+
const input = "https://example.com?key=value";
75+
expect(url.parse(input)).toBe(input);
76+
});
77+
78+
test("url normalize flag", () => {
79+
const normalizeUrl = z.url({ normalize: true });
80+
expect(normalizeUrl.parse("https://example.com?key=value"))
81+
.toBe("https://example.com/?key=value");
82+
});
83+
```
84+
85+
## Running Tests
86+
87+
### Run Specific Tests
88+
```bash
89+
pnpm vitest run src/v4/classic/tests/string.test.ts -t "url"
90+
```
91+
92+
### Run All Tests
93+
```bash
94+
pnpm test
95+
```
96+
97+
### Build Before Testing
98+
Always rebuild when testing core changes:
99+
```bash
100+
pnpm build && pnpm test
101+
```
102+
103+
## Test Development Workflow
104+
105+
1. **Start with [play.ts](mdc:play.ts)** for initial exploration
106+
2. **Write proper tests** once behavior is confirmed
107+
3. **Test edge cases** and error conditions
108+
4. **Verify backward compatibility**
109+
5. **Run full test suite** before committing
110+
111+
Remember: Features without tests are incomplete. Every new feature or bug fix should include corresponding test coverage.

packages/docs/content/api.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,13 @@ schema.parse("http://example.com"); // ❌
357357
```
358358
</Callout>
359359

360+
To normalize URLs, use the `normalize` flag. This will overwrite the input value with the [normalized URL](https://chatgpt.com/share/6881547f-bebc-800f-9093-f5981e277c2c) returned by `new URL()`.
361+
362+
```ts
363+
new URL("HTTP://ExAmPle.com:80/./a/../b?X=1#f oo").href
364+
// => "http://example.com/b?X=1#f%20oo"
365+
```
366+
360367
### ISO datetimes
361368

362369
As you may have noticed, Zod string includes a few date/time related validations. These validations are regular expression based, so they are not as strict as a full date/time library. However, they are very convenient for validating user input.

packages/zod/src/v4/classic/tests/recursive-types.test.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -325,13 +325,6 @@ test("object utilities with recursive types", () => {
325325
RequiredNode,
326326
RequiredMaskedNode,
327327
]);
328-
329-
// Verify they can be used without causing circularity issues
330-
// expect(PickedNode._zod.def.shape.id).toBeDefined();
331-
// expect("children" in OmittedNode._zod.def.shape).toBe(false);
332-
// expect(MergedNode._zod.def.shape.metadata).toBeDefined();
333-
// expect(PartialNode._zod.def.shape.id).toBeDefined();
334-
// expect(RequiredNode._zod.def.shape.id).toBeDefined();
335328
});
336329

337330
test("recursion compatibility", () => {

packages/zod/src/v4/classic/tests/string.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,83 @@ test("url validations", () => {
318318
expect(() => url.parse("https://")).toThrow();
319319
});
320320

321+
test("url preserves original input", () => {
322+
const url = z.string().url();
323+
324+
// Test the specific case from the user report
325+
const input = "https://example.com?key=NUXOmHqWNVTapJkJJHw8BfD155AuqhH_qju_5fNmQ4ZHV7u8";
326+
const output = url.parse(input);
327+
expect(output).toBe(input); // Should preserve the original input exactly
328+
329+
// Test other cases where URL constructor would normalize
330+
expect(url.parse("https://example.com?foo=bar")).toBe("https://example.com?foo=bar");
331+
expect(url.parse("http://example.com?test=123")).toBe("http://example.com?test=123");
332+
expect(url.parse("https://sub.example.com?param=value&other=data")).toBe(
333+
"https://sub.example.com?param=value&other=data"
334+
);
335+
336+
// Test cases with trailing slashes are preserved
337+
expect(url.parse("https://example.com/")).toBe("https://example.com/");
338+
expect(url.parse("https://example.com/path/")).toBe("https://example.com/path/");
339+
340+
// Test cases with paths and query parameters
341+
expect(url.parse("https://example.com/path?query=param")).toBe("https://example.com/path?query=param");
342+
});
343+
344+
test("url trims whitespace", () => {
345+
const url = z.string().url();
346+
347+
// Test trimming whitespace from URLs
348+
expect(url.parse(" https://example.com ")).toBe("https://example.com");
349+
expect(url.parse(" https://example.com/path?query=param ")).toBe("https://example.com/path?query=param");
350+
expect(url.parse("\t\nhttps://example.com\t\n")).toBe("https://example.com");
351+
expect(url.parse(" https://example.com?key=value ")).toBe("https://example.com?key=value");
352+
353+
// Test that URLs without extra whitespace are unchanged
354+
expect(url.parse("https://example.com")).toBe("https://example.com");
355+
expect(url.parse("https://example.com/path")).toBe("https://example.com/path");
356+
});
357+
358+
test("url normalize flag", () => {
359+
const normalizeUrl = z.url({ normalize: true });
360+
const preserveUrl = z.url(); // normalize: false/undefined by default
361+
362+
// Test that normalize flag causes URL normalization
363+
expect(normalizeUrl.parse("https://example.com?key=value")).toBe("https://example.com/?key=value");
364+
expect(normalizeUrl.parse("http://example.com?test=123")).toBe("http://example.com/?test=123");
365+
366+
// Test with already normalized URLs
367+
expect(normalizeUrl.parse("https://example.com/")).toBe("https://example.com/");
368+
expect(normalizeUrl.parse("https://example.com/path?query=param")).toBe("https://example.com/path?query=param");
369+
370+
// Test complex URLs with normalization
371+
expect(normalizeUrl.parse("https://example.com/../?key=value")).toBe("https://example.com/?key=value");
372+
expect(normalizeUrl.parse("https://example.com/./path?key=value")).toBe("https://example.com/path?key=value");
373+
374+
// Compare with non-normalize behavior
375+
expect(preserveUrl.parse("https://example.com?key=value")).toBe("https://example.com?key=value");
376+
expect(preserveUrl.parse("http://example.com?test=123")).toBe("http://example.com?test=123");
377+
378+
// Test trimming with normalize
379+
expect(normalizeUrl.parse(" https://example.com?key=value ")).toBe("https://example.com/?key=value");
380+
expect(preserveUrl.parse(" https://example.com?key=value ")).toBe("https://example.com?key=value");
381+
});
382+
383+
test("url normalize with hostname and protocol constraints", () => {
384+
const constrainedNormalizeUrl = z.url({
385+
normalize: true,
386+
protocol: /^https$/,
387+
hostname: /^example\.com$/,
388+
});
389+
390+
// Test that normalization works with constraints
391+
expect(constrainedNormalizeUrl.parse("https://example.com?key=value")).toBe("https://example.com/?key=value");
392+
393+
// Test that constraints are still enforced
394+
expect(() => constrainedNormalizeUrl.parse("http://example.com?key=value")).toThrow();
395+
expect(() => constrainedNormalizeUrl.parse("https://other.com?key=value")).toThrow();
396+
});
397+
321398
test("httpurl", () => {
322399
const httpUrl = z.url({
323400
protocol: /^https?$/,

packages/zod/src/v4/core/schemas.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,7 @@ export const $ZodEmail: core.$constructor<$ZodEmail> = /*@__PURE__*/ core.$const
417417
export interface $ZodURLDef extends $ZodStringFormatDef<"url"> {
418418
hostname?: RegExp | undefined;
419419
protocol?: RegExp | undefined;
420+
normalize?: boolean | undefined;
420421
}
421422
export interface $ZodURLInternals extends $ZodStringFormatInternals<"url"> {
422423
def: $ZodURLDef;
@@ -430,10 +431,10 @@ export const $ZodURL: core.$constructor<$ZodURL> = /*@__PURE__*/ core.$construct
430431
$ZodStringFormat.init(inst, def);
431432
inst._zod.check = (payload) => {
432433
try {
433-
const orig = payload.value;
434+
// Trim whitespace from input
435+
const trimmed = payload.value.trim();
434436
// @ts-ignore
435-
const url = new URL(orig);
436-
const href = url.href;
437+
const url = new URL(trimmed);
437438

438439
if (def.hostname) {
439440
def.hostname.lastIndex = 0;
@@ -465,11 +466,13 @@ export const $ZodURL: core.$constructor<$ZodURL> = /*@__PURE__*/ core.$construct
465466
}
466467
}
467468

468-
// payload.value = url.href;
469-
if (!orig.endsWith("/") && href.endsWith("/")) {
470-
payload.value = href.slice(0, -1);
469+
// Set the output value based on normalize flag
470+
if (def.normalize) {
471+
// Use normalized URL
472+
payload.value = url.href;
471473
} else {
472-
payload.value = href;
474+
// Preserve the original input (trimmed)
475+
payload.value = trimmed;
473476
}
474477

475478
return;

0 commit comments

Comments
 (0)