Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
{
"name": "json-schema-test-suite",
"version": "0.1.0",
"type": "module",
"description": "A language agnostic test suite for the JSON Schema specifications",
"repository": "github:json-schema-org/JSON-Schema-Test-Suite",
"keywords": [
"json-schema",
"tests"
],
"author": "http://json-schema.org",
"license": "MIT"
"license": "MIT",
"dependencies": {
"@hyperjump/browser": "^1.3.1",
"@hyperjump/json-pointer": "^1.1.1",
"@hyperjump/json-schema": "^1.17.2",
"@hyperjump/pact": "^1.4.0",
"@hyperjump/uri": "^1.3.2",
"json-stringify-deterministic": "^1.0.12"
}
}
74 changes: 74 additions & 0 deletions scripts/add-test-ids.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as fs from "node:fs";
import * as crypto from "node:crypto";
import jsonStringify from "json-stringify-deterministic";
import { normalize } from "./normalize.js";
import { loadRemotes } from "./load-remotes.js";

const DIALECT_MAP = {
"https://json-schema.org/draft/2020-12/schema": "https://json-schema.org/draft/2020-12/schema",
"https://json-schema.org/draft/2019-09/schema": "https://json-schema.org/draft/2019-09/schema",
"http://json-schema.org/draft-07/schema#": "http://json-schema.org/draft-07/schema#",
"http://json-schema.org/draft-06/schema#": "http://json-schema.org/draft-06/schema#",
"http://json-schema.org/draft-04/schema#": "http://json-schema.org/draft-04/schema#"
};

function getDialectUri(schema) {
if (schema.$schema && DIALECT_MAP[schema.$schema]) {
return DIALECT_MAP[schema.$schema];
}
return "https://json-schema.org/draft/2020-12/schema";
}
Comment on lines +7 to +20
Copy link
Member

@jdesrosiers jdesrosiers Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a couple of issues here.

First, you can't use 2020-12 as the default dialect. The default needs to be the dialect associated with the directory the test file is in. For example, the convention to use $schema in every test case schema was only adopted starting in 2019-09. So, tests for draft-04/6/7 don't have $schema. That means this script will treat all of those tests as 2020-12 schemas, which would be very wrong.

This whole function should be unnecessary anyway. This is already handled internally by @hyperjump/json-schema. The result of this function is only used to pass to the registerSchema function, which should be the dialect from the directory the test file is in, not from the schema.


function generateTestId(normalizedSchema, testData, testValid) {
return crypto
.createHash("md5")
.update(jsonStringify(normalizedSchema) + jsonStringify(testData) + testValid)
.digest("hex");
}

async function addIdsToFile(filePath) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should need to pass the dialect based on the directory to this function.

console.log("Reading:", filePath);
const tests = JSON.parse(fs.readFileSync(filePath, "utf8"));
let changed = false;
let added = 0;

if (!Array.isArray(tests)) {
console.log("Expected an array at top level, got:", typeof tests);
return;
}
Comment on lines +35 to +38
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be necessary. There's already automation to check the test file against the schema. By the time we run this script, we should be confident that the test file is in the right format.


for (const testCase of tests) {
if (!Array.isArray(testCase.tests)) continue;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More unnecessary defensive programming.


const dialectUri = getDialectUri(testCase.schema || {});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary defensive programming.

Suggested change
const dialectUri = getDialectUri(testCase.schema || {});
const dialectUri = getDialectUri(testCase.schema);

const normalizedSchema = await normalize(testCase.schema || true, dialectUri);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary defensive programming.

Suggested change
const normalizedSchema = await normalize(testCase.schema || true, dialectUri);
const normalizedSchema = await normalize(testCase.schema, dialectUri);


for (const test of testCase.tests) {
if (!test.id) {
test.id = generateTestId(normalizedSchema, test.data, test.valid);
changed = true;
added++;
}
}
}

if (changed) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you really need a changed flag? Can't you just check if added is greater than 0?

fs.writeFileSync(filePath, JSON.stringify(tests, null, 2) + "\n");
console.log(`✓ Added ${added} IDs`);
} else {
console.log("✓ All tests already have IDs");
}
}

// Load remotes for all dialects
const remotesPaths = ["./remotes"];
for (const dialectUri of Object.values(DIALECT_MAP)) {
for (const path of remotesPaths) {
if (fs.existsSync(path)) {
loadRemotes(dialectUri, path);
}
}
}
Comment on lines +63 to +71
Copy link
Member

@jdesrosiers jdesrosiers Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're not going to be able to add remotes for all dialects at the same time. You're going to have to run this script separately for each dialect. Most remotes don't have $schema and are expected to run as the dialect of test case that's being tested. So, you can't load them at the same time because you can't have two schemas with the same id and different dialects. Your edits to loadRemotes are a result of this misunderstanding. I don't think you should need to make changes to that function.

I suggest making the first argument of the script the local draft identifier, then convert that to the right dialect URI to pass to addIdsToFile. Then the second argument can be the optional filePath, where all files are processed if the none is given.


const filePath = process.argv[2] || "tests/draft2020-12/enum.json";
addIdsToFile(filePath).catch(console.error);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch is unnecessary here. Just let it throw if there's an error.

143 changes: 143 additions & 0 deletions scripts/check-test-ids.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as crypto from "node:crypto";
import jsonStringify from "json-stringify-deterministic";
import { normalize } from "./normalize.js";
import { loadRemotes } from "./load-remotes.js";

const DIALECT_MAP = {
"https://json-schema.org/draft/2020-12/schema": "https://json-schema.org/draft/2020-12/schema",
"https://json-schema.org/draft/2019-09/schema": "https://json-schema.org/draft/2019-09/schema",
"http://json-schema.org/draft-07/schema#": "http://json-schema.org/draft-07/schema#",
"http://json-schema.org/draft-06/schema#": "http://json-schema.org/draft-06/schema#",
"http://json-schema.org/draft-04/schema#": "http://json-schema.org/draft-04/schema#"
};

function* jsonFiles(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
yield* jsonFiles(full);
} else if (entry.isFile() && entry.name.endsWith(".json")) {
yield full;
}
}
}

function getDialectUri(schema) {
if (schema.$schema && DIALECT_MAP[schema.$schema]) {
return DIALECT_MAP[schema.$schema];
}
return "https://json-schema.org/draft/2020-12/schema";
}

function generateTestId(normalizedSchema, testData, testValid) {
return crypto
.createHash("md5")
.update(jsonStringify(normalizedSchema) + jsonStringify(testData) + testValid)
.digest("hex");
}

async function checkVersion(dir) {
const missingIdFiles = new Set();
const duplicateIdFiles = new Set();
const mismatchedIdFiles = new Set();
const idMap = new Map();

console.log(`Checking tests in ${dir}...`);

for (const file of jsonFiles(dir)) {
const tests = JSON.parse(fs.readFileSync(file, "utf8"));

for (let i = 0; i < tests.length; i++) {
const testCase = tests[i];
if (!Array.isArray(testCase.tests)) continue;

const dialectUri = getDialectUri(testCase.schema || {});
const normalizedSchema = await normalize(testCase.schema || true, dialectUri);

for (let j = 0; j < testCase.tests.length; j++) {
const test = testCase.tests[j];

if (!test.id) {
missingIdFiles.add(file);
console.log(` ✗ Missing ID: ${file} | ${testCase.description} | ${test.description}`);
continue;
}

const expectedId = generateTestId(normalizedSchema, test.data, test.valid);

if (test.id !== expectedId) {
mismatchedIdFiles.add(file);
console.log(` ✗ Mismatched ID: ${file}`);
console.log(` Test: ${testCase.description} | ${test.description}`);
console.log(` Current ID: ${test.id}`);
console.log(` Expected ID: ${expectedId}`);
}

if (idMap.has(test.id)) {
const existing = idMap.get(test.id);
duplicateIdFiles.add(file);
duplicateIdFiles.add(existing.file);
console.log(` ✗ Duplicate ID: ${test.id}`);
console.log(` First: ${existing.file} | ${existing.testCase} | ${existing.test}`);
console.log(` Second: ${file} | ${testCase.description} | ${test.description}`);
} else {
idMap.set(test.id, {
file,
testCase: testCase.description,
test: test.description
});
}
}
}
}

console.log("\n" + "=".repeat(60));
console.log("Summary:");
console.log("=".repeat(60));

console.log("\nFiles with missing IDs:");
if (missingIdFiles.size === 0) {
console.log(" ✓ None");
} else {
for (const f of missingIdFiles) console.log(` - ${f}`);
}

console.log("\nFiles with mismatched IDs:");
if (mismatchedIdFiles.size === 0) {
console.log(" ✓ None");
} else {
for (const f of mismatchedIdFiles) console.log(` - ${f}`);
}

console.log("\nFiles with duplicate IDs:");
if (duplicateIdFiles.size === 0) {
console.log(" ✓ None");
} else {
for (const f of duplicateIdFiles) console.log(` - ${f}`);
}

const hasErrors = missingIdFiles.size > 0 || mismatchedIdFiles.size > 0 || duplicateIdFiles.size > 0;

console.log("\n" + "=".repeat(60));
if (hasErrors) {
console.log("❌ Check failed - issues found");
process.exit(1);
} else {
console.log("✅ All checks passed!");
}
}

// Load remotes
const remotesPaths = ["./remotes"];
for (const dialectUri of Object.values(DIALECT_MAP)) {
for (const path of remotesPaths) {
if (fs.existsSync(path)) {
loadRemotes(dialectUri, path);
}
}
}

const dir = process.argv[2] || "tests/draft2020-12";
checkVersion(dir).catch(console.error);
36 changes: 36 additions & 0 deletions scripts/load-remotes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// scripts/load-remotes.js
import * as fs from "node:fs";
import { toAbsoluteIri } from "@hyperjump/uri";
import { registerSchema } from "@hyperjump/json-schema/draft-2020-12";

// Keep track of which remote URLs we've already registered
const loadedRemotes = new Set();

export const loadRemotes = (dialectId, filePath, url = "") => {
if (!fs.existsSync(filePath)) {
console.warn(`Warning: Remotes path not found: ${filePath}`);
return;
}

fs.readdirSync(filePath, { withFileTypes: true }).forEach((entry) => {
if (entry.isFile() && entry.name.endsWith(".json")) {
const remotePath = `${filePath}/${entry.name}`;
const remoteUrl = `http://localhost:1234${url}/${entry.name}`;

// If we've already registered this URL once, skip it
if (loadedRemotes.has(remoteUrl)) {
return;
}

const remote = JSON.parse(fs.readFileSync(remotePath, "utf8"));

// Only register if $schema matches dialect OR there's no $schema
if (!remote.$schema || toAbsoluteIri(remote.$schema) === dialectId) {
registerSchema(remote, remoteUrl, dialectId);
loadedRemotes.add(remoteUrl); // ✅ Remember we've registered it
}
} else if (entry.isDirectory()) {
loadRemotes(dialectId, `${filePath}/${entry.name}`, `${url}/${entry.name}`);
}
});
};
Loading
Loading