1import * as Cause from "effect/Cause";
2import * as Exit from "effect/Exit";
3import * as Result from "effect/Result";
4import * as Schema from "effect/Schema";
5import { describe, expect, it } from "vite-plus/test";
6
7import {
8 decodeJsonResult,
9 extractJsonObject,
10 formatSchemaError,
11 fromLenientJson,
12} from "./schemaJson.ts";
13
14const decodeLenientJson = Schema.decodeUnknownSync(fromLenientJson(Schema.Unknown));
15
16describe("schemaJson helpers", () => {
17 it("extracts a balanced JSON object from surrounding text", () => {
18 expect(
19 extractJsonObject(`Sure, here is the JSON:
20\`\`\`json
21{
22 "subject": "Update README",
23 "body": ""
24}
25\`\`\`
26Done.`),
27 ).toBe(`{
28 "subject": "Update README",
29 "body": ""
30}`);
31 });
32
33 it("ignores braces inside strings while finding the object boundary", () => {
34 expect(
35 extractJsonObject('prefix {"message":"literal } brace","nested":{"ok":true}} suffix'),
36 ).toBe('{"message":"literal } brace","nested":{"ok":true}}');
37 });
38
39 it("returns trimmed input when no JSON object starts", () => {
40 expect(extractJsonObject(" no structured output ")).toBe("no structured output");
41 });
42
43 it("decodes JSON with comments and trailing commas", () => {
44 expect(
45 decodeLenientJson(`{
46 // Comments are valid in settings files.
47 "enabled": true,
48 "values": [1, 2,],
49 }`),
50 ).toEqual({
51 enabled: true,
52 values: [1, 2],
53 });
54 });
55
56 it("rejects malformed JSON after lenient preprocessing", () => {
57 expect(() => decodeLenientJson('{ "enabled": true,, }')).toThrow();
58 });
59
60 it("preserves commas before brackets inside string values", () => {
61 // A comma inside a string value that happens to precede `}`/`]` must not
62 // be stripped as if it were a trailing comma.
63 expect(decodeLenientJson('{"note":"a,]"}')).toEqual({ note: "a,]" });
64 expect(decodeLenientJson('{"list":["x,}"]}')).toEqual({ list: ["x,}"] });
65 // Genuine trailing commas are still removed.
66 expect(decodeLenientJson('{"values":[1, 2,],}')).toEqual({ values: [1, 2] });
67 });
68
69 it("formats schema failures with paths without exposing invalid values", () => {
70 const decodeCredential = decodeJsonResult(Schema.Struct({ token: Schema.Number }));
71 const decoded = decodeCredential('{"token":"credential=secret-value"}');
72
73 expect(Result.isFailure(decoded)).toBe(true);
74 if (Result.isFailure(decoded)) {
75 expect(formatSchemaError(decoded.failure)).toBe('Invalid type\n at ["token"]');
76 }
77 });
78
79 it("preserves nested paths reported by schema filters", () => {
80 const decode = decodeJsonResult(
81 Schema.String.check(
82 Schema.makeFilter(() => ({
83 path: ["session", "token"],
84 issue: "credential is invalid",
85 })),
86 ),
87 );
88 const decoded = decode('"credential=secret-value"');
89
90 expect(Result.isFailure(decoded)).toBe(true);
91 if (Result.isFailure(decoded)) {
92 const diagnostic = formatSchemaError(decoded.failure);
93 expect(diagnostic).toBe('Invalid value\n at ["session"]["token"]');
94 expect(diagnostic).not.toContain("credential=secret-value");
95 }
96 });
97
98 it("does not expose malformed lenient JSON input in diagnostics", () => {
99 const decode = Schema.decodeUnknownExit(fromLenientJson(Schema.Unknown));
100 const exit = decode('{"token":"credential=secret-value",,}');
101
102 expect(Exit.isFailure(exit)).toBe(true);
103 if (Exit.isFailure(exit)) {
104 const diagnostic = formatSchemaError(exit.cause);
105 expect(diagnostic).toBe("Invalid value");
106 expect(diagnostic).not.toContain("credential=secret-value");
107 }
108 });
109
110 it("summarizes unexpected defects without serializing their messages", () => {
111 const diagnostic = formatSchemaError(Cause.die(new Error("credential=secret-value")));
112
113 expect(diagnostic).toBe(
114 "Schema validation failed (failureCount=0, defectCount=1, interruptionCount=0).",
115 );
116 });
117
118 it("bounds the number of formatted schema issues", () => {
119 const decode = decodeJsonResult(Schema.Struct({ token: Schema.Number }));
120 const failures: Array<Cause.Cause<Schema.SchemaError>> = [];
121 for (let index = 0; index < 10; index += 1) {
122 const decoded = decode(`{"token":"credential=secret-value-${index}"}`);
123 if (Result.isFailure(decoded)) {
124 failures.push(decoded.failure);
125 }
126 }
127
128 const cause = Cause.fromReasons(failures.flatMap((cause) => cause.reasons));
129 const diagnostic = formatSchemaError(cause);
130 expect(diagnostic.match(/Invalid type/g)).toHaveLength(8);
131 expect(diagnostic).toContain("... and 2 more issue(s)");
132 });
133
134 it("retains the omitted issue count when bounding long diagnostics", () => {
135 const longPath = Array.from({ length: 16 }, (_, index) => `${index}-${"segment".repeat(16)}`);
136 const decode = decodeJsonResult(
137 Schema.String.check(
138 Schema.makeFilter(() => ({ path: longPath, issue: "credential is invalid" })),
139 ),
140 );
141 const failures: Array<Cause.Cause<Schema.SchemaError>> = [];
142 for (let index = 0; index < 10; index += 1) {
143 const decoded = decode(`"credential=secret-value-${index}"`);
144 if (Result.isFailure(decoded)) {
145 failures.push(decoded.failure);
146 }
147 }
148
149 const cause = Cause.fromReasons(failures.flatMap((cause) => cause.reasons));
150 const diagnostic = formatSchemaError(cause);
151 expect(diagnostic.length).toBeLessThanOrEqual(2_048);
152 expect(diagnostic.endsWith("\n... and 2 more issue(s)")).toBe(true);
153 });
154});