A plain JavaScript validator for AT Protocol lexicon schemas
1import { describe, test, expect } from 'vitest';
2import { defineLexicon, validateLexicons, validateRecord } from './lexicon.js';
3import { atprotoValidateLexicons, atprotoValidateRecord } from './test/atproto-oracle.js';
4
5// Schema inputs
6import * as schemaInputs from './test/inputs/schema/index.js';
7
8// Data inputs
9import * as dataInputs from './test/inputs/data/index.js';
10
11// Format inputs
12import * as formatInputs from './test/inputs/format/index.js';
13
14// E2E inputs
15import * as e2eInputs from './test/inputs/e2e/index.js';
16
17// Collect all schema inputs
18const allSchemaInputs = [
19 ...schemaInputs.stringSchemaInputs,
20 ...schemaInputs.integerSchemaInputs,
21 ...schemaInputs.booleanSchemaInputs,
22 ...schemaInputs.objectSchemaInputs,
23 ...schemaInputs.arraySchemaInputs,
24 ...schemaInputs.unionSchemaInputs,
25 ...schemaInputs.refSchemaInputs,
26 ...schemaInputs.blobSchemaInputs,
27 ...schemaInputs.bytesSchemaInputs,
28 ...schemaInputs.tokenSchemaInputs,
29 ...schemaInputs.unknownSchemaInputs,
30 ...schemaInputs.recordSchemaInputs,
31 ...schemaInputs.paramsSchemaInputs,
32 ...schemaInputs.querySchemaInputs,
33 ...schemaInputs.procedureSchemaInputs,
34 ...schemaInputs.subscriptionSchemaInputs,
35];
36
37// Collect all data inputs (record types only - validateRecord doesn't handle query/procedure/params)
38const allDataInputs = [
39 ...dataInputs.stringDataInputs,
40 ...dataInputs.integerDataInputs,
41 ...dataInputs.objectDataInputs,
42 ...dataInputs.arrayDataInputs,
43 ...dataInputs.blobDataInputs,
44 ...dataInputs.bytesDataInputs,
45 ...dataInputs.tokenDataInputs,
46 ...dataInputs.unknownDataInputs,
47 ...dataInputs.unionDataInputs,
48 ...dataInputs.cidLinkDataInputs,
49];
50
51// Collect all format inputs
52const allFormatInputs = [
53 ...formatInputs.datetimeFormatInputs,
54 ...formatInputs.handleFormatInputs,
55 ...formatInputs.didFormatInputs,
56 ...formatInputs.uriFormatInputs,
57 ...formatInputs.atUriFormatInputs,
58 ...formatInputs.cidFormatInputs,
59 ...formatInputs.tidFormatInputs,
60 ...formatInputs.nsidFormatInputs,
61 ...formatInputs.languageFormatInputs,
62 ...formatInputs.recordKeyFormatInputs,
63];
64
65// Collect all e2e inputs
66const allE2eInputs = [
67 ...e2eInputs.bskyPostInputs,
68 ...e2eInputs.bskyProfileInputs,
69 ...e2eInputs.integrationInputs,
70];
71
72// Helper: determine expected result from test name
73// Tests with 'valid' in name should pass, 'invalid' should fail
74function expectedValid(name) {
75 if (name.includes('-valid-')) return true;
76 if (name.includes('-invalid-')) return false;
77 throw new Error(`Test name must contain '-valid-' or '-invalid-': ${name}`);
78}
79
80// ==========================================================================
81// SPEC-BASED TESTS
82// ==========================================================================
83
84describe('schema validation', () => {
85 for (const input of allSchemaInputs) {
86 test(input.name, () => {
87 const lexicons = input.additionalLexicons
88 ? [input.lexicon, ...input.additionalLexicons]
89 : [input.lexicon];
90 const result = validateLexicons(lexicons);
91 const isValid = result === null;
92 expect(isValid).toBe(expectedValid(input.name));
93 });
94 }
95});
96
97describe('data validation', () => {
98 for (const input of allDataInputs) {
99 test(input.name, () => {
100 const result = validateRecord(input.lexicons, input.collection, input.record);
101 const isValid = result === null;
102 expect(isValid).toBe(expectedValid(input.name));
103 });
104 }
105});
106
107describe('format validation', () => {
108 for (const input of allFormatInputs) {
109 test(input.name, () => {
110 const result = validateRecord(input.lexicons, input.collection, input.record);
111 const isValid = result === null;
112 expect(isValid).toBe(expectedValid(input.name));
113 });
114 }
115});
116
117describe('e2e validation', () => {
118 for (const input of allE2eInputs) {
119 test(input.name, () => {
120 const result = validateRecord(input.lexicons, input.collection, input.record);
121 const isValid = result === null;
122 expect(isValid).toBe(expectedValid(input.name));
123 });
124 }
125});
126
127// Ref data validation - validates data against resolved ref schemas
128describe('ref data validation', () => {
129 for (const input of dataInputs.refDataInputs) {
130 test(input.name, () => {
131 const result = validateRecord(input.lexicons, input.collection, input.record);
132 const isValid = result === null;
133 expect(isValid).toBe(expectedValid(input.name));
134 });
135 }
136});
137
138// Union ref data validation - validates union data against resolved ref schemas
139describe('union ref data validation', () => {
140 for (const input of dataInputs.unionRefDataInputs) {
141 test(input.name, () => {
142 const result = validateRecord(input.lexicons, input.collection, input.record);
143 const isValid = result === null;
144 expect(isValid).toBe(expectedValid(input.name));
145 });
146 }
147});
148
149// ==========================================================================
150// @ATPROTO/LEXICON COMPARISON (informational - differences are expected)
151// See docs/oracle-differences.md for details on behavioral differences.
152// ==========================================================================
153
154describe.skip('@atproto/lexicon comparison', () => {
155 describe('schema validation', () => {
156 for (const input of allSchemaInputs) {
157 test(input.name, () => {
158 const lexicons = input.additionalLexicons
159 ? [input.lexicon, ...input.additionalLexicons]
160 : [input.lexicon];
161 const atprotoResult = atprotoValidateLexicons(lexicons);
162 const ourResult = validateLexicons(lexicons);
163
164 const atprotoValid = atprotoResult === null;
165 const ourValid = ourResult === null;
166 expect(ourValid).toBe(atprotoValid);
167 });
168 }
169 });
170
171 describe('data validation', () => {
172 for (const input of allDataInputs) {
173 test(input.name, () => {
174 const atprotoResult = atprotoValidateRecord(input.lexicons, input.collection, input.record);
175 const ourResult = validateRecord(input.lexicons, input.collection, input.record);
176
177 const atprotoValid = atprotoResult === null;
178 const ourValid = ourResult === null;
179 expect(ourValid).toBe(atprotoValid);
180 });
181 }
182 });
183
184 describe('format validation', () => {
185 for (const input of allFormatInputs) {
186 test(input.name, () => {
187 const atprotoResult = atprotoValidateRecord(input.lexicons, input.collection, input.record);
188 const ourResult = validateRecord(input.lexicons, input.collection, input.record);
189
190 const atprotoValid = atprotoResult === null;
191 const ourValid = ourResult === null;
192 expect(ourValid).toBe(atprotoValid);
193 });
194 }
195 });
196
197 describe('e2e validation', () => {
198 for (const input of allE2eInputs) {
199 test(input.name, () => {
200 const atprotoResult = atprotoValidateRecord(input.lexicons, input.collection, input.record);
201 const ourResult = validateRecord(input.lexicons, input.collection, input.record);
202
203 const atprotoValid = atprotoResult === null;
204 const ourValid = ourResult === null;
205 expect(ourValid).toBe(atprotoValid);
206 });
207 }
208 });
209
210 describe('ref data validation', () => {
211 for (const input of dataInputs.refDataInputs) {
212 test(input.name, () => {
213 const atprotoResult = atprotoValidateRecord(input.lexicons, input.collection, input.record);
214 const ourResult = validateRecord(input.lexicons, input.collection, input.record);
215
216 const atprotoValid = atprotoResult === null;
217 const ourValid = ourResult === null;
218 expect(ourValid).toBe(atprotoValid);
219 });
220 }
221 });
222});