Skip to content

Commit df7967e

Browse files
committed
fix(edge): fix some edge cases and add more tests
1 parent f249657 commit df7967e

File tree

15 files changed

+860
-24
lines changed

15 files changed

+860
-24
lines changed

lib/bundle.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,17 @@ function bundle<S extends object = JSONSchema, O extends ParserOptions<S> = Pars
3535
options: O,
3636
) {
3737
// console.log('Bundling $ref pointers in %s', parser.$refs._root$Ref.path);
38+
const rootScopeBase = parser.$refs._root$Ref.dynamicIdScope
39+
? getSchemaBasePath(parser.$refs._root$Ref.path!, parser.schema)
40+
: parser.$refs._root$Ref.path!;
3841

3942
// Build an inventory of all $ref pointers in the JSON Schema
4043
const inventory: InventoryEntry[] = [];
4144
crawl<S, O>(
4245
parser,
4346
"schema",
4447
parser.$refs._root$Ref.path + "#",
45-
parser.$refs._root$Ref.path!,
48+
rootScopeBase,
4649
parser.$refs._root$Ref.dynamicIdScope,
4750
"#",
4851
0,
@@ -96,7 +99,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
9699
const isExcludedPath = bundleOptions.excludedPathMatcher || (() => false);
97100

98101
if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !isExcludedPath(pathFromRoot)) {
99-
const currentScopeBase = dynamicIdScope ? getSchemaBasePath(scopeBase, obj) : scopeBase;
102+
const currentScopeBase = scopeBase;
100103
if ($Ref.isAllowed$Ref(obj)) {
101104
inventory$Ref(parent, key, path, currentScopeBase, dynamicIdScope, pathFromRoot, indirections, inventory, $refs, options);
102105
} else {
@@ -121,14 +124,17 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
121124
const keyPath = Pointer.join(path, key);
122125
const keyPathFromRoot = Pointer.join(pathFromRoot, key);
123126
const value = obj[key];
127+
const childScopeBase =
128+
dynamicIdScope && value && typeof value === "object" && !ArrayBuffer.isView(value)
129+
? getSchemaBasePath(currentScopeBase, value)
130+
: currentScopeBase;
124131

125132
if ($Ref.isAllowed$Ref(value)) {
126-
const valueScopeBase = dynamicIdScope ? getSchemaBasePath(currentScopeBase, value) : currentScopeBase;
127133
inventory$Ref(
128134
obj,
129135
key,
130136
keyPath,
131-
valueScopeBase,
137+
childScopeBase,
132138
dynamicIdScope,
133139
keyPathFromRoot,
134140
indirections,
@@ -137,7 +143,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
137143
options,
138144
);
139145
} else {
140-
crawl(obj, key, keyPath, currentScopeBase, dynamicIdScope, keyPathFromRoot, indirections, inventory, $refs, options);
146+
crawl(obj, key, keyPath, childScopeBase, dynamicIdScope, keyPathFromRoot, indirections, inventory, $refs, options);
141147
}
142148

143149
// We need to ensure that we have an object to work with here because we may be crawling

lib/dereference.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,14 @@ function dereference<S extends object = JSONSchema, O extends ParserOptions<S> =
2121
options: O,
2222
) {
2323
const start = Date.now();
24+
const rootScopeBase = parser.$refs._root$Ref.dynamicIdScope
25+
? getSchemaBasePath(parser.$refs._root$Ref.path!, parser.schema)
26+
: parser.$refs._root$Ref.path!;
2427
// console.log('Dereferencing $ref pointers in %s', parser.$refs._root$Ref.path);
2528
const dereferenced = crawl<S, O>(
2629
parser.schema,
2730
parser.$refs._root$Ref.path!,
28-
parser.$refs._root$Ref.path!,
31+
rootScopeBase,
2932
parser.$refs._root$Ref.dynamicIdScope,
3033
"#",
3134
new Set(),
@@ -92,7 +95,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
9295
if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !isExcludedPath(pathFromRoot)) {
9396
parents.add(obj);
9497
processedObjects.add(obj);
95-
const currentScopeBase = dynamicIdScope ? getSchemaBasePath(scopeBase, obj) : scopeBase;
98+
const currentScopeBase = scopeBase;
9699

97100
if ($Ref.isAllowed$Ref(obj, options)) {
98101
dereferenced = dereference$Ref(
@@ -123,14 +126,17 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
123126
}
124127

125128
const value = obj[key];
129+
const childScopeBase =
130+
dynamicIdScope && value && typeof value === "object" && !ArrayBuffer.isView(value)
131+
? getSchemaBasePath(currentScopeBase, value)
132+
: currentScopeBase;
126133
let circular;
127134

128135
if ($Ref.isAllowed$Ref(value, options)) {
129-
const valueScopeBase = dynamicIdScope ? getSchemaBasePath(currentScopeBase, value) : currentScopeBase;
130136
dereferenced = dereference$Ref(
131137
value,
132138
keyPath,
133-
valueScopeBase,
139+
childScopeBase,
134140
dynamicIdScope,
135141
keyPathFromRoot,
136142
parents,
@@ -183,7 +189,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
183189
dereferenced = crawl(
184190
value,
185191
keyPath,
186-
currentScopeBase,
192+
childScopeBase,
187193
dynamicIdScope,
188194
keyPathFromRoot,
189195
parents,

lib/pointer.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
9595

9696
// Crawl the object, one token at a time
9797
this.value = unwrapOrThrow(obj);
98-
if (this.$ref.dynamicIdScope) {
98+
if (this.$ref.dynamicIdScope && !isAliasedResource(this.$ref)) {
9999
this.scopeBase = getSchemaBasePath(this.scopeBase, this.value);
100100
}
101101

@@ -196,7 +196,7 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
196196

197197
// Crawl the object, one token at a time
198198
this.value = unwrapOrThrow(obj);
199-
if (this.$ref.dynamicIdScope) {
199+
if (this.$ref.dynamicIdScope && !isAliasedResource(this.$ref)) {
200200
this.scopeBase = getSchemaBasePath(this.scopeBase, this.value);
201201
}
202202

@@ -327,18 +327,16 @@ function resolveIf$Ref<S extends object = JSONSchema, O extends ParserOptions<S>
327327
// This JSON reference "extends" the resolved value, rather than simply pointing to it.
328328
// So the resolved path does NOT change. Just the value does.
329329
pointer.value = $Ref.dereference(pointer.value, resolved.value, options);
330-
if (pointer.$ref.dynamicIdScope) {
331-
pointer.scopeBase = getSchemaBasePath(pointer.scopeBase, pointer.value);
332-
}
333330
return false;
334331
} else {
335332
// Resolve the reference
336333
pointer.$ref = resolved.$ref;
337334
pointer.path = resolved.path;
338335
pointer.value = resolved.value;
339-
pointer.scopeBase = pointer.$ref.dynamicIdScope
340-
? getSchemaBasePath(pointer.$ref.path!, pointer.value)
341-
: pointer.$ref.path!;
336+
// `pointer.$ref.path` is already the canonical location of the resolved resource.
337+
// Re-applying the resource's own `$id` here would duplicate nested path segments
338+
// such as `nested/nested/foo.json`.
339+
pointer.scopeBase = pointer.$ref.path!;
342340
}
343341

344342
return true;
@@ -385,3 +383,7 @@ function unwrapOrThrow(value: unknown) {
385383
function isRootPath(pathFromRoot: string | unknown): boolean {
386384
return typeof pathFromRoot == "string" && Pointer.parse(pathFromRoot).length == 0;
387385
}
386+
387+
function isAliasedResource($ref: $Ref<any, any>) {
388+
return Boolean($ref.path && $ref.path in $ref.$refs._aliases);
389+
}

lib/resolve-external.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,14 @@ function resolveExternal<S extends object = JSONSchema, O extends ParserOptions<
2929
}
3030

3131
try {
32+
const rootScopeBase = parser.$refs._root$Ref.dynamicIdScope
33+
? getSchemaBasePath(parser.$refs._root$Ref.path!, parser.schema)
34+
: parser.$refs._root$Ref.path!;
3235
// console.log('Resolving $ref pointers in %s', parser.$refs._root$Ref.path);
3336
const promises = crawl(
3437
parser.schema,
3538
parser.$refs._root$Ref.path + "#",
36-
parser.$refs._root$Ref.path!,
39+
rootScopeBase,
3740
parser.$refs._root$Ref.dynamicIdScope,
3841
parser.$refs,
3942
options,
@@ -75,7 +78,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
7578

7679
if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !seen.has(obj)) {
7780
seen.add(obj); // Track previously seen objects to avoid infinite recursion
78-
const currentScopeBase = dynamicIdScope ? getSchemaBasePath(scopeBase, obj) : scopeBase;
81+
const currentScopeBase = scopeBase;
7982
if ($Ref.isExternal$Ref(obj)) {
8083
promises.push(resolve$Ref<S, O>(obj, path, currentScopeBase, dynamicIdScope, $refs, options));
8184
}
@@ -85,7 +88,9 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
8588
const keyPath = Pointer.join(path, key);
8689
const value = obj[key as keyof typeof obj] as string | JSONSchema | Buffer | undefined;
8790
const childScopeBase =
88-
dynamicIdScope && $Ref.isExternal$Ref(value) ? getSchemaBasePath(currentScopeBase, value) : currentScopeBase;
91+
dynamicIdScope && value && typeof value === "object" && !ArrayBuffer.isView(value)
92+
? getSchemaBasePath(currentScopeBase, value)
93+
: currentScopeBase;
8994
promises = promises.concat(crawl(value, keyPath, childScopeBase, dynamicIdScope, $refs, options, seen, external));
9095
}
9196
}

lib/util/url.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,10 @@ export function fromFileSystemPath(path: string) {
426426
* Converts a URL to a local filesystem path.
427427
*/
428428
export function toFileSystemPath(path: string | undefined, keepFileProtocol?: boolean): string {
429+
// Bare "%" characters are valid in filesystem paths, but they make `decodeURI` throw.
430+
// Escape only the non-encoded ones so percent-encoded sequences still decode normally.
431+
path = path!.replace(/%(?![0-9A-Fa-f]{2})/g, "%25");
432+
429433
// Step 1: `decodeURI` will decode characters such as Cyrillic characters, spaces, etc.
430434
path = decodeURI(path!);
431435

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"test": "vitest --coverage",
1616
"test:specific": "vitest invalid",
1717
"test:node": "yarn test",
18-
"test:browser": "cross-env BROWSER=\"true\" yarn test",
18+
"test:browser": "node ./test/fixtures/run-browser-tests.mjs",
1919
"test:update": "vitest -u",
2020
"test:watch": "vitest -w"
2121
},
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { createServer } from "node:net";
2+
import { spawn } from "node:child_process";
3+
4+
async function getOpenPort() {
5+
const probe = createServer();
6+
7+
await new Promise((resolve, reject) => {
8+
probe.once("error", reject);
9+
probe.listen(0, "127.0.0.1", resolve);
10+
});
11+
12+
const address = probe.address();
13+
if (!address || typeof address === "string") {
14+
throw new Error("Expected an ephemeral TCP port for browser tests");
15+
}
16+
17+
await new Promise((resolve, reject) => {
18+
probe.close((error) => {
19+
if (error) {
20+
reject(error);
21+
} else {
22+
resolve();
23+
}
24+
});
25+
});
26+
27+
return address.port;
28+
}
29+
30+
const port = await getOpenPort();
31+
const child = spawn(process.execPath, [".yarn/releases/yarn-4.13.0.cjs", "test"], {
32+
stdio: "inherit",
33+
env: {
34+
...process.env,
35+
BROWSER: "true",
36+
TEST_HTTP_PORT: String(port),
37+
TEST_HTTP_BASE_URL: `http://localhost:${port}/`,
38+
},
39+
});
40+
41+
child.on("exit", (code, signal) => {
42+
if (signal) {
43+
process.kill(process.pid, signal);
44+
return;
45+
}
46+
47+
process.exit(code ?? 1);
48+
});

test/fixtures/server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import path from "path";
1010

1111
const __dirname = dirname(__filename);
1212
const root = path.join(__dirname, "..", "..");
13+
const serverPort = Number(process.env.TEST_HTTP_PORT) || 3000;
1314
const setup = async () => {
1415
const server = http
1516
.createServer(function (request, response) {
@@ -55,7 +56,7 @@ const setup = async () => {
5556
}
5657
});
5758
})
58-
.listen(3000);
59+
.listen(serverPort);
5960

6061
return () => {
6162
// teardown

0 commit comments

Comments
 (0)