lighthouse 13.5.0-dev.20261008 → 13.5.0-dev.20261009

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,352 +7,269 @@
7
7
  /**
8
8
  * @fileoverview Unit tests for ARD Schema validation logic.
9
9
  * Tests mirror the validation rules from
10
- * ards-project/ard-spec/conformance/bin/conformance-test (validate_manifest).
10
+ * ards-project/ard-spec/conformance/bin/conformance-test (validate_manifest, ARD v0.91).
11
11
  */
12
12
 
13
13
  import {ConformanceTester} from './ard.js';
14
14
 
15
+ /**
16
+ * @param {Record<string, any>} overrides
17
+ */
18
+ function entry(overrides = {}) {
19
+ return {
20
+ identifier: 'urn:air:google:search:web-search',
21
+ displayName: 'Web Search API',
22
+ type: 'application/mcp-server-card+json',
23
+ url: 'https://example.com/mcp.json',
24
+ representativeQueries: ['search web', 'find articles'],
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ /**
30
+ * @param {Array<{message: string}>} list
31
+ * @param {string} text
32
+ */
33
+ function has(list, text) {
34
+ return list.some(i => i.message.includes(text));
35
+ }
36
+
15
37
  describe('ARD Schema validation (ported from ard-spec)', () => {
16
38
  /** @type {ConformanceTester} */
17
39
  let tester;
18
-
40
+
19
41
  beforeEach(() => {
20
42
  tester = new ConformanceTester();
21
43
  });
22
44
 
45
+ it('fails with Error when manifest is malformed JSON', () => {
46
+ tester.validate_manifest('{ invalid json', 'test');
47
+ expect(has(tester.errors, 'Malformed JSON in manifest')).toEqual(true);
48
+ });
49
+
23
50
  it('fails with Error when manifest root is not an object', () => {
24
51
  tester.validate_manifest(JSON.stringify('just a string'), 'test');
25
- expect(
26
- tester.errors.some(i => i.message.includes('Missing required \'specVersion\' root property'))
27
- ).toEqual(true);
52
+ expect(has(tester.errors, 'ArdManifest validation failed')).toEqual(true);
53
+ expect(has(tester.errors, 'Missing required \'entries\' array')).toEqual(true);
54
+
55
+ tester = new ConformanceTester();
56
+ tester.validate_manifest('null', 'test');
57
+ expect(has(tester.errors, 'Missing required \'entries\' array')).toEqual(true);
28
58
  });
29
59
 
30
- it('fails with Error when root is missing specVersion', () => {
31
- const manifest = {
32
- entries: [],
33
- };
34
- tester.validate_manifest(JSON.stringify(manifest), 'test');
35
- expect(
36
- tester.errors.some(i => i.message.includes('Missing required \'specVersion\''))
37
- ).toEqual(true);
60
+ it('passes when specVersion is absent (ARD requires only entries)', () => {
61
+ const passed = tester.validate_manifest(JSON.stringify({entries: [entry()]}), 'test');
62
+ expect(passed).toEqual(true);
63
+ expect(tester.errors).toEqual([]);
64
+ expect(tester.warnings).toEqual([]);
38
65
  });
39
66
 
40
- it('returns warning when specVersion is unrecognized', () => {
67
+ it('ignores specVersion and other unrecognized top-level members', () => {
41
68
  const manifest = {
42
69
  specVersion: '2.0',
43
- entries: [
44
- {
45
- identifier: 'urn:air:google:search:web-search',
46
- displayName: 'Web Search',
47
- type: 'application/mcp-server-card+json',
48
- url: 'https://example.com/api',
49
- representativeQueries: ['query 1', 'query 2'],
50
- },
51
- ],
70
+ host: {displayName: 'Example'},
71
+ entries: [entry()],
52
72
  };
53
- tester.validate_manifest(JSON.stringify(manifest), 'test');
54
- expect(
55
- tester.warnings.some(
56
- i => i.message.includes('Unrecognized \'specVersion\'')
57
- )
58
- ).toEqual(true);
73
+ const passed = tester.validate_manifest(JSON.stringify(manifest), 'test');
74
+ expect(passed).toEqual(true);
75
+ expect(tester.errors).toEqual([]);
76
+ expect(tester.warnings).toEqual([]);
59
77
  });
60
78
 
61
- it('fails with Error when root contains deprecated collections field (ADR-0003)', () => {
79
+ it('warns, but does not fail, when root contains deprecated collections (ADR-0003)', () => {
62
80
  const manifest = {
63
81
  specVersion: '1.0',
64
82
  collections: [],
65
- entries: [],
83
+ entries: [entry()],
66
84
  };
67
- tester.validate_manifest(JSON.stringify(manifest), 'test');
68
- expect(
69
- tester.errors.some(
70
- i => i.message.includes('Deprecated field check: Found \'collections\'')
71
- )
72
- ).toEqual(true);
85
+ const passed = tester.validate_manifest(JSON.stringify(manifest), 'test');
86
+ expect(passed).toEqual(true);
87
+ expect(tester.errors).toEqual([]);
88
+ expect(tester.warnings).toEqual([
89
+ {
90
+ element: 'Root',
91
+ message: expect.stringContaining('Found \'collections\' at root'),
92
+ },
93
+ ]);
73
94
  });
74
95
 
75
96
  it('fails with Error when entries is missing or not an array', () => {
76
- const manifestMissing = {specVersion: '1.0'};
77
- tester.validate_manifest(JSON.stringify(manifestMissing), 'test');
78
- expect(
79
- tester.errors.some(i => i.message.includes('Missing required \'entries\' array'))
80
- ).toEqual(true);
97
+ tester.validate_manifest(JSON.stringify({specVersion: '1.0'}), 'test');
98
+ expect(has(tester.errors, 'ArdManifest validation failed: \'entries\' is a required property'))
99
+ .toEqual(true);
100
+ expect(has(tester.errors, 'Missing required \'entries\' array')).toEqual(true);
81
101
 
82
102
  tester = new ConformanceTester();
83
- const manifestNotArray = {specVersion: '1.0', entries: 'not-an-array'};
84
- tester.validate_manifest(JSON.stringify(manifestNotArray), 'test');
85
- expect(
86
- tester.errors.some(i => i.message.includes('\'entries\' must be a JSON array'))
87
- ).toEqual(true);
103
+ tester.validate_manifest(JSON.stringify({entries: 'not-an-array'}), 'test');
104
+ expect(has(tester.errors, '\'entries\' must be a JSON array')).toEqual(true);
88
105
  });
89
106
 
90
107
  it('fails with Error when entry is not a JSON object', () => {
91
- const manifest = {
92
- specVersion: '1.0',
93
- entries: ['not an object'],
94
- };
108
+ tester.validate_manifest(JSON.stringify({entries: ['not an object', null]}), 'test');
109
+ expect(tester.errors).toContainEqual({
110
+ element: 'Entry #0',
111
+ message: expect.stringContaining('ArdEntry validation failed'),
112
+ });
113
+ expect(tester.errors).toContainEqual({
114
+ element: 'Entry #1',
115
+ message: 'Missing required \'identifier\'.',
116
+ });
117
+ });
118
+
119
+ it('validates each entry against ArdEntry and labels the error', () => {
120
+ const manifest = {entries: [entry({identifier: 'http://not-a-urn'})]};
95
121
  tester.validate_manifest(JSON.stringify(manifest), 'test');
96
- expect(
97
- tester.errors.some(i => i.message.includes('Missing required \'identifier\''))
98
- ).toEqual(true);
122
+ expect(tester.errors).toContainEqual({
123
+ element: 'Root',
124
+ message: expect.stringMatching(
125
+ /^ArdManifest validation failed: .* at path 'entries\.0\.identifier'$/),
126
+ });
127
+ expect(tester.errors).toContainEqual({
128
+ element: 'Web Search API',
129
+ message: expect.stringMatching(/^ArdEntry validation failed: .* at path 'identifier'$/),
130
+ });
99
131
  });
100
132
 
101
133
  it('fails with Error when entry is missing identifier or has invalid RFC 8141 URN', () => {
102
- const manifestMissingId = {
103
- specVersion: '1.0',
104
- entries: [
105
- {displayName: 'Test', type: 'application/ai-catalog+json', url: 'https://example.com'},
106
- ],
107
- };
108
- tester.validate_manifest(JSON.stringify(manifestMissingId), 'test');
109
- expect(
110
- tester.errors.some(i => i.message.includes('Missing required \'identifier\''))
111
- ).toEqual(true);
134
+ tester.validate_manifest(JSON.stringify({entries: [entry({identifier: undefined})]}), 'test');
135
+ expect(has(tester.errors, 'Missing required \'identifier\'')).toEqual(true);
112
136
 
113
137
  tester = new ConformanceTester();
114
- const manifestInvalidUrn = {
115
- specVersion: '1.0',
116
- entries: [
117
- {
118
- identifier: 'http://not-a-urn',
119
- displayName: 'Test',
120
- type: 'application/ai-catalog+json',
121
- url: 'https://example.com',
122
- },
123
- ],
124
- };
125
- tester.validate_manifest(JSON.stringify(manifestInvalidUrn), 'test');
126
- expect(
127
- tester.errors.some(
128
- i => i.message.includes('does not match RFC 8141 URN pattern')
129
- )
130
- ).toEqual(true);
138
+ tester.validate_manifest(
139
+ JSON.stringify({entries: [entry({identifier: 'http://not-a-urn'})]}), 'test');
140
+ expect(has(tester.errors, 'does not match RFC 8141 URN pattern')).toEqual(true);
131
141
  });
132
142
 
133
143
  it('fails with Error when entry is missing displayName or type', () => {
134
- const manifestMissingName = {
135
- specVersion: '1.0',
136
- entries: [
137
- {
138
- identifier: 'urn:air:google:test:agent',
139
- type: 'application/ai-catalog+json',
140
- url: 'https://example.com',
141
- },
142
- ],
143
- };
144
- tester.validate_manifest(JSON.stringify(manifestMissingName), 'test');
145
- expect(
146
- tester.errors.some(
147
- i => i.message.includes('Missing required \'displayName\'')
148
- )
149
- ).toEqual(true);
144
+ tester.validate_manifest(JSON.stringify({entries: [entry({displayName: undefined})]}), 'test');
145
+ expect(has(tester.errors, 'Missing required \'displayName\'')).toEqual(true);
150
146
 
151
147
  tester = new ConformanceTester();
152
- const manifestMissingType = {
153
- specVersion: '1.0',
154
- entries: [
155
- {
156
- identifier: 'urn:air:google:test:agent',
157
- displayName: 'Test Agent',
158
- url: 'https://example.com',
159
- },
160
- ],
161
- };
162
- tester.validate_manifest(JSON.stringify(manifestMissingType), 'test');
163
- expect(
164
- tester.errors.some(i => i.message.includes('Missing required \'type\''))
165
- ).toEqual(true);
148
+ tester.validate_manifest(JSON.stringify({entries: [entry({type: undefined})]}), 'test');
149
+ expect(has(tester.errors, 'Missing required \'type\'')).toEqual(true);
166
150
  });
167
151
 
168
- it('returns warning when entry uses a non-standard media type', () => {
169
- const manifest = {
170
- specVersion: '1.0',
171
- entries: [
172
- {
173
- identifier: 'urn:air:google:test:agent',
174
- displayName: 'Test Agent',
175
- type: 'application/custom-unknown+json',
176
- url: 'https://example.com',
177
- representativeQueries: ['q1', 'q2'],
178
- },
179
- ],
180
- };
181
- tester.validate_manifest(JSON.stringify(manifest), 'test');
182
- expect(
183
- tester.warnings.some(
184
- i => i.message.includes('not one of standard discovery types')
185
- )
186
- ).toEqual(true);
152
+ describe('media type classification', () => {
153
+ /**
154
+ * @param {unknown} type
155
+ */
156
+ function classify(type) {
157
+ tester = new ConformanceTester();
158
+ tester.validate_manifest(JSON.stringify({entries: [entry({type})]}), 'test');
159
+ return tester;
160
+ }
161
+
162
+ it('accepts standard types, case-insensitively and with parameter whitespace', () => {
163
+ for (const type of [
164
+ 'application/ai-catalog+json',
165
+ 'APPLICATION/AI-Catalog+JSON',
166
+ 'text/markdown; profile="urn:air:agent-skills"',
167
+ 'text/markdown;profile="urn:air:agent-skills"',
168
+ ]) {
169
+ const result = classify(type);
170
+ expect(result.warnings).toEqual([]);
171
+ expect(result.infos).toEqual([]);
172
+ }
173
+ });
174
+
175
+ it('reports application extension types as info only', () => {
176
+ const result = classify('application/custom-unknown+json');
177
+ expect(result.errors).toEqual([]);
178
+ expect(result.warnings).toEqual([]);
179
+ expect(has(result.infos, 'is a valid application extension media type')).toEqual(true);
180
+ });
181
+
182
+ it('warns on non-application types that are not standard', () => {
183
+ const result = classify('text/plain');
184
+ expect(has(result.warnings,
185
+ 'is not one of standard discovery types: [\'application/ai-catalog+json\'')).toEqual(true);
186
+ });
187
+
188
+ it('warns on the deprecated MCP server media type (ADR-0008)', () => {
189
+ const result = classify('application/mcp-server+json');
190
+ expect(has(result.warnings,
191
+ 'was renamed by ADR-0008. Use \'application/mcp-server-card+json\'.')).toEqual(true);
192
+ });
193
+
194
+ it('warns when a standard base type has missing or unrecognized parameters', () => {
195
+ expect(has(classify('text/markdown').warnings,
196
+ 'missing required parameters: profile="urn:air:agent-skills"')).toEqual(true);
197
+ expect(has(classify('application/ai-catalog+json; v=1').warnings,
198
+ 'unrecognized parameters: v')).toEqual(true);
199
+ });
200
+
201
+ it('warns on invalid or non-string media types', () => {
202
+ expect(has(classify('not a media type').warnings,
203
+ 'is not a valid IANA media type')).toEqual(true);
204
+ expect(has(classify('application/ai-catalog+json; a=1; a=2').warnings,
205
+ 'is not a valid IANA media type')).toEqual(true);
206
+ expect(has(classify(5).warnings, 'Media type must be a string; got int.')).toEqual(true);
207
+ });
187
208
  });
188
209
 
189
210
  it('fails with Error when value-or-reference constraint is violated', () => {
190
- const manifestBoth = {
191
- specVersion: '1.0',
192
- entries: [{
193
- identifier: 'urn:air:google:test:agent',
194
- displayName: 'Test',
195
- type: 'application/ai-catalog+json',
196
- url: 'https://example.com',
197
- data: {foo: 'bar'},
198
- }],
199
- };
200
- tester.validate_manifest(JSON.stringify(manifestBoth), 'test');
201
- expect(
202
- tester.errors.some(
203
- i => i.message.includes('both \'url\' and \'data\' are provided')
204
- )
205
- ).toEqual(true);
211
+ tester.validate_manifest(JSON.stringify({entries: [entry({data: {foo: 'bar'}})]}), 'test');
212
+ expect(has(tester.errors, 'both \'url\' and \'data\' are provided')).toEqual(true);
206
213
 
207
214
  tester = new ConformanceTester();
208
- const manifestNeither = {
209
- specVersion: '1.0',
210
- entries: [{
211
- identifier: 'urn:air:google:test:agent',
212
- displayName: 'Test',
213
- type: 'application/ai-catalog+json',
214
- }],
215
- };
216
- tester.validate_manifest(JSON.stringify(manifestNeither), 'test');
217
- expect(
218
- tester.errors.some(
219
- i => i.message.includes('neither \'url\' nor \'data\' is provided')
220
- )
221
- ).toEqual(true);
215
+ tester.validate_manifest(JSON.stringify({entries: [entry({url: undefined})]}), 'test');
216
+ expect(has(tester.errors, 'neither \'url\' nor \'data\' is provided')).toEqual(true);
222
217
  });
223
218
 
224
219
  it('validates representativeQueries type and size constraints', () => {
225
- const manifestNonArray = {
226
- specVersion: '1.0',
227
- entries: [{
228
- identifier: 'urn:air:google:test:agent',
229
- displayName: 'Test',
230
- type: 'application/ai-catalog+json',
231
- url: 'https://example.com',
232
- representativeQueries: 'not an array',
233
- }],
234
- };
235
- tester.validate_manifest(JSON.stringify(manifestNonArray), 'test');
236
- expect(
237
- tester.errors.some(i => i.message.includes('must be an array of strings'))
238
- ).toEqual(true);
220
+ tester.validate_manifest(
221
+ JSON.stringify({entries: [entry({representativeQueries: 'not an array'})]}), 'test');
222
+ expect(has(tester.errors, 'must be an array of strings')).toEqual(true);
239
223
 
240
224
  tester = new ConformanceTester();
241
- const manifestNonString = {
242
- specVersion: '1.0',
243
- entries: [{
244
- identifier: 'urn:air:google:test:agent',
245
- displayName: 'Test',
246
- type: 'application/ai-catalog+json',
247
- url: 'https://example.com',
248
- representativeQueries: [123, 456],
249
- }],
250
- };
251
- tester.validate_manifest(JSON.stringify(manifestNonString), 'test');
252
- expect(
253
- tester.errors.some(i => i.message.includes('is not a string'))
254
- ).toEqual(true);
225
+ tester.validate_manifest(
226
+ JSON.stringify({entries: [entry({representativeQueries: [123, 456]})]}), 'test');
227
+ expect(has(tester.errors, 'is not a string')).toEqual(true);
255
228
 
256
229
  tester = new ConformanceTester();
257
- const manifestFewQueries = {
258
- specVersion: '1.0',
259
- entries: [{
260
- identifier: 'urn:air:google:test:agent',
261
- displayName: 'Test',
262
- type: 'application/ai-catalog+json',
263
- url: 'https://example.com',
264
- representativeQueries: ['only one query'],
265
- }],
266
- };
267
- tester.validate_manifest(JSON.stringify(manifestFewQueries), 'test');
268
- expect(
269
- tester.warnings.some(i => i.message.includes('2 to 5 queries are recommended'))
270
- ).toEqual(true);
230
+ tester.validate_manifest(
231
+ JSON.stringify({entries: [entry({representativeQueries: ['only one query']})]}), 'test');
232
+ expect(has(tester.warnings, '2 to 5 queries are recommended')).toEqual(true);
271
233
 
272
234
  tester = new ConformanceTester();
273
- const manifestMissingQueries = {
274
- specVersion: '1.0',
275
- entries: [{
276
- identifier: 'urn:air:google:test:agent',
277
- displayName: 'Test',
278
- type: 'application/ai-catalog+json',
279
- url: 'https://example.com',
280
- }],
281
- };
282
- tester.validate_manifest(JSON.stringify(manifestMissingQueries), 'test');
283
- expect(
284
- tester.warnings.some(i => i.message.includes('Missing \'representativeQueries\''))
285
- ).toEqual(true);
235
+ tester.validate_manifest(
236
+ JSON.stringify({entries: [entry({representativeQueries: undefined})]}), 'test');
237
+ expect(tester.errors).toEqual([]);
238
+ expect(has(tester.warnings, 'No \'representativeQueries\'')).toEqual(true);
286
239
  });
287
240
 
288
241
  it('validates trustManifest constraints', () => {
289
- const manifestInvalidTrust = {
290
- specVersion: '1.0',
291
- entries: [{
292
- identifier: 'urn:air:google:test:agent',
293
- displayName: 'Test',
294
- type: 'application/ai-catalog+json',
295
- url: 'https://example.com',
296
- trustManifest: 'not-an-object',
297
- }],
298
- };
299
- tester.validate_manifest(JSON.stringify(manifestInvalidTrust), 'test');
300
- expect(
301
- tester.errors.some(
302
- i => i.message.includes('\'trustManifest\' must be a JSON object')
303
- )
304
- ).toEqual(true);
242
+ tester.validate_manifest(
243
+ JSON.stringify({entries: [entry({trustManifest: 'not-an-object'})]}), 'test');
244
+ expect(has(tester.errors, '\'trustManifest\' must be a JSON object')).toEqual(true);
305
245
 
306
246
  tester = new ConformanceTester();
307
- const manifestMissingIdentity = {
308
- specVersion: '1.0',
309
- entries: [{
310
- identifier: 'urn:air:google:test:agent',
311
- displayName: 'Test',
312
- type: 'application/ai-catalog+json',
313
- url: 'https://example.com',
314
- trustManifest: {},
315
- }],
316
- };
317
- tester.validate_manifest(JSON.stringify(manifestMissingIdentity), 'test');
318
- expect(
319
- tester.errors.some(
320
- i => i.message.includes('missing required \'identity\' field')
321
- )
322
- ).toEqual(true);
247
+ tester.validate_manifest(JSON.stringify({entries: [entry({trustManifest: {}})]}), 'test');
248
+ expect(has(tester.errors, 'missing required \'identity\' field')).toEqual(true);
323
249
  });
324
250
 
325
251
  it('passes completely when schema is fully conformant with URL or inline data', () => {
326
252
  const validManifestUrl = {
327
- specVersion: '1.0',
328
- entries: [
329
- {
330
- identifier: 'urn:air:google:search:web-search',
331
- displayName: 'Web Search API',
332
- type: 'application/mcp-server-card+json',
333
- url: 'https://example.com/mcp.json',
334
- representativeQueries: ['search web', 'find articles'],
335
- trustManifest: {identity: 'google.com'},
336
- },
337
- ],
253
+ entries: [entry({trustManifest: {identity: 'google.com'}})],
338
254
  };
339
- tester.validate_manifest(JSON.stringify(validManifestUrl), 'test');
340
- expect(tester.errors.length).toEqual(0);
255
+ expect(tester.validate_manifest(JSON.stringify(validManifestUrl), 'test')).toEqual(true);
256
+ expect(tester.errors).toEqual([]);
257
+ expect(tester.warnings).toEqual([]);
258
+ expect(tester.infos).toEqual([]);
341
259
 
342
260
  tester = new ConformanceTester();
343
261
  const validManifestData = {
344
- specVersion: '1.0',
345
262
  entries: [
346
- {
263
+ entry({
347
264
  identifier: 'urn:air:google:tools:math-helper',
348
265
  displayName: 'Math Helper',
349
266
  type: 'application/agent-card+json',
267
+ url: undefined,
350
268
  data: {functions: ['add', 'subtract']},
351
- representativeQueries: ['calculate sum', 'compute subtraction'],
352
- },
269
+ }),
353
270
  ],
354
271
  };
355
- tester.validate_manifest(JSON.stringify(validManifestData), 'test');
356
- expect(tester.errors.length).toEqual(0);
272
+ expect(tester.validate_manifest(JSON.stringify(validManifestData), 'test')).toEqual(true);
273
+ expect(tester.errors).toEqual([]);
357
274
  });
358
275
  });
@@ -1,3 +1,4 @@
1
+ export type ParsedMediaType = [string, Array<[string, string]>];
1
2
  export type ValidationError = {
2
3
  element: string;
3
4
  message: string;
@@ -13,6 +14,8 @@ export class ConformanceTester {
13
14
  errors: ValidationError[];
14
15
  /** @type {ValidationError[]} */
15
16
  warnings: ValidationError[];
17
+ /** @type {ValidationError[]} */
18
+ infos: ValidationError[];
16
19
  /**
17
20
  * @param {string} message
18
21
  * @param {string} [element='Root']
@@ -23,7 +26,15 @@ export class ConformanceTester {
23
26
  * @param {string} [element='Root']
24
27
  */
25
28
  add_warning(message: string, element?: string): void;
26
- /** @param {any} manifest_data */
29
+ /**
30
+ * @param {string} message
31
+ * @param {string} [element='Root']
32
+ */
33
+ add_info(message: string, element?: string): void;
34
+ /**
35
+ * Validate the manifest as an ArdManifest, then every entry as an ArdEntry.
36
+ * @param {any} manifest_data
37
+ */
27
38
  run_json_schema_validation(manifest_data: any): boolean;
28
39
  /**
29
40
  * @param {string} raw_content
@@ -31,4 +42,9 @@ export class ConformanceTester {
31
42
  */
32
43
  validate_manifest(raw_content: string, source_label: string): boolean;
33
44
  }
45
+ /**
46
+ * @param {unknown} media_type
47
+ * @return {['warning'|'info', string]|null}
48
+ */
49
+ export function classify_media_type(media_type: unknown): ["warning" | "info", string] | null;
34
50
  //# sourceMappingURL=ard.d.ts.map