Skip to content
Open
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
4 changes: 2 additions & 2 deletions language-server/src/features/Hover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,8 +426,8 @@ _hyperjump-json-language-server_`
});

test("hover with an invalid schema", async () => {
const diagnostics = new Promise<void>((resolve) => {
client.onNotification("textDocument/publishDiagnostics", () => {
const diagnostics: Promise<void> = new Promise((resolve) => {
client.onNotification(PublishDiagnosticsNotification.type, () => {
resolve();
});
});
Expand Down
247 changes: 240 additions & 7 deletions language-server/src/features/SchemaValidation.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, test, expect, afterEach, beforeEach } from "vitest";
import { PublishDiagnosticsNotification } from "vscode-languageserver";
import { TestClient } from "../test/TestClient.ts";
import { unregisterSchema } from "@hyperjump/json-schema";

import { promises as fs } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";

import type { Diagnostic } from "vscode-languageserver";

Expand All @@ -18,10 +21,6 @@ describe("Schema Validation", () => {
await client.stop();
});

afterEach(() => {
unregisterSchema(fixtureSchemaUri);
});

test("JSON Validation using Hyperjump - Valid Case", async () => {
const diagnostics: Promise<Diagnostic[]> = new Promise((resolve) => {
client.onNotification(PublishDiagnosticsNotification.type, (params) => {
Expand Down Expand Up @@ -296,7 +295,7 @@ describe("Schema Validation", () => {
}
}`);

await client.writeDocument("instance.json", `{
const instanceUri = await client.writeDocument("instance.json", `{
"$schema": "${fixtureSchemaUri}",
"name": "Alice",
"age" : "not a number"
Expand All @@ -307,7 +306,9 @@ describe("Schema Validation", () => {

const secondValidation: Promise<Diagnostic[]> = new Promise((resolve) => {
client.onNotification(PublishDiagnosticsNotification.type, (params) => {
resolve(params.diagnostics);
if (params.uri === instanceUri) {
resolve(params.diagnostics);
}
});
});

Expand Down Expand Up @@ -715,4 +716,236 @@ describe("Schema Validation", () => {
}
]);
});

test("should register self-identifying schema and validate document using its $id", async () => {
const schemaId = "https://example.com/my-workspace-schema";

// 1. Create a self-identifying schema file in the workspace
await client.writeDocument("my-schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "${schemaId}",
"type": "object",
"properties": {
"foo": { "type": "string" }
}
}`);

// 2. Create and open an instance file that references the local schema by its $id
let instanceUri: string;
const diagnostics: Promise<Diagnostic[]> = new Promise((resolve) => {
client.onNotification(PublishDiagnosticsNotification.type, (params) => {
if (params.uri === instanceUri) {
resolve(params.diagnostics);
}
});
});

instanceUri = await client.writeDocument("instance.json", `{
"$schema": "${schemaId}",
"foo": 42
}`);
await client.openDocument("instance.json");

await expect(diagnostics).resolves.toEqual([
{
message: "Expected a ⁨string⁩",
range: {
start: { line: 2, character: 13 },
end: { line: 2, character: 15 }
},
severity: 1,
source: "hyperjump-json-language-server"
}
]);
});

test("should update registered schema and re-validate dependent documents", async () => {
const schemaId = "https://example.com/my-workspace-schema";

// 1. Create initial schema
await client.writeDocument("my-schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "${schemaId}",
"type": "object",
"properties": {
"foo": { "type": "string" }
}
}`);

// 2. Create instance and resolve initial validation
const instanceUri = await client.writeDocument("instance.json", `{
"$schema": "${schemaId}",
"foo": 42
}`);
const initialValidation: Promise<Diagnostic[]> = new Promise((resolve) => {
client.onNotification(PublishDiagnosticsNotification.type, (params) => {
if (params.uri === instanceUri) {
resolve(params.diagnostics);
}
});
});
await client.openDocument("instance.json");

await expect(initialValidation).resolves.toEqual([
{
message: "Expected a ⁨string⁩",
range: {
start: { line: 2, character: 13 },
end: { line: 2, character: 15 }
},
severity: 1,
source: "hyperjump-json-language-server"
}
]);

// 3. Update the schema to allow a number for "foo"
const updatedDiagnostics: Promise<Diagnostic[]> = new Promise((resolve) => {
client.onNotification(PublishDiagnosticsNotification.type, (params) => {
if (params.uri === instanceUri) {
resolve(params.diagnostics);
}
});
});

await client.writeDocument("my-schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "${schemaId}",
"type": "object",
"properties": {
"foo": { "type": "number" }
}
}`);

await expect(updatedDiagnostics).resolves.toEqual([]);
});

test("should unregister schema when schema file is deleted", async () => {
const schemaId = "https://example.com/my-workspace-schema";

// 1. Create schema and wait for it to be registered on the server
await client.writeDocument("delete-schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "${schemaId}",
"type": "object",
"properties": {
"baz": { "type": "boolean" }
}
}`);

const instanceUri = await client.writeDocument("instance.json", `{
"$schema": "${schemaId}",
"baz": "true"
}`);
const diagnostics: Promise<Diagnostic[]> = new Promise((resolve) => {
client.onNotification(PublishDiagnosticsNotification.type, (params) => {
if (params.uri === instanceUri) {
resolve(params.diagnostics);
}
});
});
await client.openDocument("instance.json");

await expect(diagnostics).resolves.toEqual([
{
message: "Expected a ⁨boolean⁩",
range: {
start: { line: 2, character: 13 },
end: { line: 2, character: 19 }
},
severity: 1,
source: "hyperjump-json-language-server"
}
]);

// 2. Delete the schema file and wait for unregistration to complete on the server
const updatedDiagnostics: Promise<Diagnostic[]> = new Promise((resolve) => {
client.onNotification(PublishDiagnosticsNotification.type, (params) => {
if (params.uri === instanceUri) {
resolve(params.diagnostics);
}
});
});
await client.deleteDocument("delete-schema.json");

// 3. Try to validate an instance against the deleted schema (should fail to load schema)
await expect(updatedDiagnostics).resolves.toEqual([
{
message: `Unable to load resource '${schemaId}'.`,
range: {
start: { line: 1, character: 17 },
end: { line: 1, character: 58 }
},
severity: 1,
source: "hyperjump-json-language-server"
}
]);
});
});

describe("Workspace scan", async () => {
let client: TestClient | undefined;

afterEach(async () => {
await client?.stop();
});

test("should discover and register self-identifying schemas on startup", async () => {
const schemaId = "https://example.com/my-workspace-schema";

client = new TestClient();
await client.writeDocument("startup-schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "${schemaId}",
"type": "object",
"properties": {
"bar": { "type": "number" }
}
}`);
await client.start();

const instanceUri = await client.writeDocument("instance.json", `{
"$schema": "${schemaId}",
"bar": "not a number"
}`);
const diagnostics: Promise<Diagnostic[]> = new Promise((resolve) => {
client?.onNotification(PublishDiagnosticsNotification.type, (params) => {
if (params.uri === instanceUri) {
resolve(params.diagnostics);
}
});
});
await client.openDocument("instance.json");

await expect(diagnostics).resolves.toEqual([
{
message: "Expected a ⁨number⁩",
range: {
start: { line: 2, character: 13 },
end: { line: 2, character: 27 }
},
severity: 1,
source: "hyperjump-json-language-server"
}
]);
});

test("should handle and log error when processing invalid local schema on startup", async () => {
client = new TestClient();

const workspacePath = fileURLToPath(await client.workspaceFolder);
await fs.mkdir(join(workspacePath, "startup-broken-schema.json"));

const errorLoggedPromise = new Promise<string>((resolve) => {
client?.onNotification("window/logMessage", (params: any) => {
if (params.message.includes("Failed to process local schema at")) {
resolve(params.message);
}
});
});

await client.start();

const loggedMessage = await errorLoggedPromise;
expect(loggedMessage).toContain("Failed to process local schema at");
});
});
Loading
Loading