Files
bun.sh/test/js/web/urlpattern/urlpattern.test.ts
Jarred Sumner 0305f3d4d2 feat(url): implement URLPattern API (#25168)
## Summary

Implements the [URLPattern Web
API](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) based
on WebKit's implementation. URLPattern provides declarative pattern
matching for URLs, similar to how regular expressions work for strings.

### Features

- **Constructor**: Create patterns from strings or `URLPatternInit`
dictionaries
- **`test()`**: Check if a URL matches the pattern (returns boolean)
- **`exec()`**: Extract matched groups from a URL (returns
`URLPatternResult` or null)
- **Pattern properties**: `protocol`, `username`, `password`,
`hostname`, `port`, `pathname`, `search`, `hash`
- **`hasRegExpGroups`**: Detect if the pattern uses custom regular
expressions

### Example Usage

```js
// Match URLs with a user ID parameter
const pattern = new URLPattern({ pathname: '/users/:id' });

pattern.test('https://example.com/users/123'); // true
pattern.test('https://example.com/posts/456'); // false

const result = pattern.exec('https://example.com/users/123');
console.log(result.pathname.groups.id); // "123"

// Wildcard matching
const filesPattern = new URLPattern({ pathname: '/files/*' });
const match = filesPattern.exec('https://example.com/files/image.png');
console.log(match.pathname.groups[0]); // "image.png"
```

## Implementation Notes

- Adapted from WebKit's URLPattern implementation
- Modified JS bindings to work with Bun's infrastructure (simpler
`convertDictionary` patterns, WTF::Variant handling)
- Added IsoSubspaces for proper GC integration

## Test Plan

- [x] 408 tests from Web Platform Tests pass
- [x] Tests fail with system Bun (URLPattern not defined), pass with
debug build
- [x] Manual testing of basic functionality

Fixes https://github.com/oven-sh/bun/issues/2286

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-11-28 00:04:30 -08:00

210 lines
7.4 KiB
TypeScript

// Test data from Web Platform Tests
// https://github.com/web-platform-tests/wpt/blob/master/LICENSE.md
import { describe, expect, test } from "bun:test";
import testData from "./urlpatterntestdata.json";
const kComponents = ["protocol", "username", "password", "hostname", "port", "pathname", "search", "hash"] as const;
type Component = (typeof kComponents)[number];
interface TestEntry {
pattern: any[];
inputs?: any[];
expected_obj?: Record<string, string> | "error";
expected_match?: Record<string, any> | null | "error";
exactly_empty_components?: string[];
}
function getExpectedPatternString(entry: TestEntry, component: Component): string {
// If the test case explicitly provides an expected pattern string, use that
if (entry.expected_obj && typeof entry.expected_obj === "object" && entry.expected_obj[component] !== undefined) {
return entry.expected_obj[component];
}
// Determine if there is a baseURL present
let baseURL: URL | null = null;
if (entry.pattern.length > 0 && entry.pattern[0].baseURL) {
baseURL = new URL(entry.pattern[0].baseURL);
} else if (entry.pattern.length > 1 && typeof entry.pattern[1] === "string") {
baseURL = new URL(entry.pattern[1]);
}
const EARLIER_COMPONENTS: Record<Component, Component[]> = {
protocol: [],
hostname: ["protocol"],
port: ["protocol", "hostname"],
username: [],
password: [],
pathname: ["protocol", "hostname", "port"],
search: ["protocol", "hostname", "port", "pathname"],
hash: ["protocol", "hostname", "port", "pathname", "search"],
};
if (entry.exactly_empty_components?.includes(component)) {
return "";
} else if (typeof entry.pattern[0] === "object" && entry.pattern[0][component]) {
return entry.pattern[0][component];
} else if (typeof entry.pattern[0] === "object" && EARLIER_COMPONENTS[component].some(c => c in entry.pattern[0])) {
return "*";
} else if (baseURL && component !== "username" && component !== "password") {
let base_value = (baseURL as any)[component] as string;
if (component === "protocol") base_value = base_value.substring(0, base_value.length - 1);
else if (component === "search" || component === "hash") base_value = base_value.substring(1);
return base_value;
} else {
return "*";
}
}
function getExpectedComponentResult(
entry: TestEntry,
component: Component,
): { input: string; groups: Record<string, string | undefined> } {
let expected_obj = entry.expected_match?.[component];
if (!expected_obj) {
expected_obj = { input: "", groups: {} as Record<string, string | undefined> };
if (!entry.exactly_empty_components?.includes(component)) {
expected_obj.groups["0"] = "";
}
}
// Convert null to undefined in groups
for (const key in expected_obj.groups) {
if (expected_obj.groups[key] === null) {
expected_obj.groups[key] = undefined;
}
}
return expected_obj;
}
describe("URLPattern", () => {
describe("WPT tests", () => {
for (const entry of testData as TestEntry[]) {
const testName = `Pattern: ${JSON.stringify(entry.pattern)} Inputs: ${JSON.stringify(entry.inputs)}`;
test(testName, () => {
// Test construction error
if (entry.expected_obj === "error") {
expect(() => new URLPattern(...entry.pattern)).toThrow(TypeError);
return;
}
const pattern = new URLPattern(...entry.pattern);
// Verify compiled pattern properties
for (const component of kComponents) {
const expected = getExpectedPatternString(entry, component);
expect(pattern[component]).toBe(expected);
}
// Test match error
if (entry.expected_match === "error") {
expect(() => pattern.test(...(entry.inputs ?? []))).toThrow(TypeError);
expect(() => pattern.exec(...(entry.inputs ?? []))).toThrow(TypeError);
return;
}
// Test test() method
expect(pattern.test(...(entry.inputs ?? []))).toBe(!!entry.expected_match);
// Test exec() method
const exec_result = pattern.exec(...(entry.inputs ?? []));
if (!entry.expected_match || typeof entry.expected_match !== "object") {
expect(exec_result).toBe(entry.expected_match);
return;
}
const expected_inputs = entry.expected_match.inputs ?? entry.inputs;
// Verify inputs
expect(exec_result!.inputs.length).toBe(expected_inputs!.length);
for (let i = 0; i < exec_result!.inputs.length; i++) {
const input = exec_result!.inputs[i];
const expected_input = expected_inputs![i];
if (typeof input === "string") {
expect(input).toBe(expected_input);
} else {
for (const component of kComponents) {
expect(input[component]).toBe(expected_input[component]);
}
}
}
// Verify component results
for (const component of kComponents) {
const expected = getExpectedComponentResult(entry, component);
expect(exec_result![component]).toEqual(expected);
}
});
}
});
describe("constructor edge cases", () => {
test("unclosed token with URL object - %(", () => {
expect(() => new URLPattern(new URL("https://example.org/%("))).toThrow(TypeError);
});
test("unclosed token with URL object - %((", () => {
expect(() => new URLPattern(new URL("https://example.org/%(("))).toThrow(TypeError);
});
test("unclosed token with string - (\\", () => {
expect(() => new URLPattern("(\\")).toThrow(TypeError);
});
test("constructor with undefined arguments", () => {
// Should not throw
new URLPattern(undefined, undefined);
});
});
describe("hasRegExpGroups", () => {
test("match-everything pattern", () => {
expect(new URLPattern({}).hasRegExpGroups).toBe(false);
});
for (const component of kComponents) {
test(`wildcard in ${component}`, () => {
expect(new URLPattern({ [component]: "*" }).hasRegExpGroups).toBe(false);
});
test(`segment wildcard in ${component}`, () => {
expect(new URLPattern({ [component]: ":foo" }).hasRegExpGroups).toBe(false);
});
test(`optional segment wildcard in ${component}`, () => {
expect(new URLPattern({ [component]: ":foo?" }).hasRegExpGroups).toBe(false);
});
test(`named regexp group in ${component}`, () => {
expect(new URLPattern({ [component]: ":foo(hi)" }).hasRegExpGroups).toBe(true);
});
test(`anonymous regexp group in ${component}`, () => {
expect(new URLPattern({ [component]: "(hi)" }).hasRegExpGroups).toBe(true);
});
if (component !== "protocol" && component !== "port") {
test(`wildcards mixed with fixed text in ${component}`, () => {
expect(new URLPattern({ [component]: "a-{:hello}-z-*-a" }).hasRegExpGroups).toBe(false);
});
test(`regexp groups mixed with fixed text in ${component}`, () => {
expect(new URLPattern({ [component]: "a-(hi)-z-(lo)-a" }).hasRegExpGroups).toBe(true);
});
}
}
test("complex pathname with no regexp", () => {
expect(new URLPattern({ pathname: "/a/:foo/:baz?/b/*" }).hasRegExpGroups).toBe(false);
});
test("complex pathname with regexp", () => {
expect(new URLPattern({ pathname: "/a/:foo/:baz([a-z]+)?/b/*" }).hasRegExpGroups).toBe(true);
});
});
});