Skip to content

Commit be65ae7

Browse files
committed
test coverage
1 parent 06f475b commit be65ae7

3 files changed

Lines changed: 435 additions & 0 deletions

File tree

test/errors.spec.ts

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,37 @@ import type { JSONRPCErrorResponse } from '../src/core.js';
33
import {
44
A2A_ERROR_CLASSES,
55
A2A_ERROR_CODE,
6+
A2A_ERROR_DOMAIN,
7+
A2A_ERROR_SPECS,
68
A2A_ERROR_SPECS_BY_CODE,
9+
A2AError,
710
ContentTypeNotSupportedError,
11+
ERROR_INFO_TYPE,
812
ExtendedAgentCardNotConfiguredError,
913
ExtensionSupportRequiredError,
1014
extractErrorMessage,
1115
fromJsonRpcErrorResponse as mapJsonRpcErrorToSdkError,
16+
fromRestErrorBody,
17+
GenericError,
18+
GRPC_STATUS,
19+
GrpcTaskNotFoundError,
20+
grpcStatusFor,
21+
HTTP_STATUS,
1222
InvalidAgentResponseError,
23+
isGrpcError,
24+
isJsonRpcError,
25+
isRestError,
26+
JsonRpcRequestMalformedError,
27+
JsonRpcTaskNotFoundError,
1328
JsonRpcTransportError as JSONRPCTransportError,
1429
PushNotificationNotSupportedError,
1530
RequestMalformedError,
31+
RestTaskNotFoundError,
32+
restStatusFor,
1633
TaskNotCancelableError,
1734
TaskNotFoundError,
35+
toJsonRpcError,
36+
toRestErrorBody,
1837
UnsupportedOperationError,
1938
VersionNotSupportedError,
2039
} from '../src/errors/index.js';
@@ -160,3 +179,353 @@ describe('extractErrorMessage', () => {
160179
expect(extractErrorMessage(cyclic)).toBe('[object Object]');
161180
});
162181
});
182+
183+
// ---------------------------------------------------------------------------
184+
// Hierarchy: every transport variant is-a semantic subclass is-a A2AError.
185+
// Guards `isRestError` / `isGrpcError` / `isJsonRpcError` narrow correctly,
186+
// and only ONE guard matches per instance (transports are exclusive).
187+
// ---------------------------------------------------------------------------
188+
189+
describe('A2AError hierarchy', () => {
190+
it('semantic class extends A2AError extends Error', () => {
191+
const e = new TaskNotFoundError({ message: 't-1' });
192+
expect(e).toBeInstanceOf(TaskNotFoundError);
193+
expect(e).toBeInstanceOf(A2AError);
194+
expect(e).toBeInstanceOf(Error);
195+
expect(e.name).toBe('TaskNotFoundError');
196+
expect(e.code).toBe(-32001);
197+
expect(e.reason).toBe('TASK_NOT_FOUND');
198+
});
199+
200+
it('RestTaskNotFoundError is-a TaskNotFoundError is-a A2AError', () => {
201+
const e = new RestTaskNotFoundError({ statusCode: 404 });
202+
expect(e).toBeInstanceOf(RestTaskNotFoundError);
203+
expect(e).toBeInstanceOf(TaskNotFoundError);
204+
expect(e).toBeInstanceOf(A2AError);
205+
expect(e).toBeInstanceOf(Error);
206+
});
207+
208+
it('GrpcTaskNotFoundError is-a TaskNotFoundError is-a A2AError', () => {
209+
const e = new GrpcTaskNotFoundError({ status: GRPC_STATUS.NOT_FOUND });
210+
expect(e).toBeInstanceOf(GrpcTaskNotFoundError);
211+
expect(e).toBeInstanceOf(TaskNotFoundError);
212+
expect(e).toBeInstanceOf(A2AError);
213+
});
214+
215+
it('JsonRpcTaskNotFoundError is-a TaskNotFoundError is-a A2AError', () => {
216+
const e = new JsonRpcTaskNotFoundError({ envelopeCode: -32001 });
217+
expect(e).toBeInstanceOf(JsonRpcTaskNotFoundError);
218+
expect(e).toBeInstanceOf(TaskNotFoundError);
219+
expect(e).toBeInstanceOf(A2AError);
220+
});
221+
222+
it('semantic class name is preserved across transport variants', () => {
223+
// Users rely on `error.name === 'TaskNotFoundError'` for logging /
224+
// instanceof-adjacent branching; the makeRest/makeGrpc/makeJsonRpc
225+
// factories override `this.name = <semantic>` for this reason.
226+
expect(new RestTaskNotFoundError().name).toBe('TaskNotFoundError');
227+
expect(new GrpcTaskNotFoundError().name).toBe('TaskNotFoundError');
228+
expect(new JsonRpcTaskNotFoundError().name).toBe('TaskNotFoundError');
229+
});
230+
});
231+
232+
describe('transport type guards', () => {
233+
it('isRestError narrows only RestA2AError instances', () => {
234+
expect(isRestError(new RestTaskNotFoundError({ statusCode: 404 }))).toBe(true);
235+
expect(isRestError(new GrpcTaskNotFoundError())).toBe(false);
236+
expect(isRestError(new JsonRpcTaskNotFoundError())).toBe(false);
237+
expect(isRestError(new TaskNotFoundError())).toBe(false); // plain semantic
238+
expect(isRestError(new Error('nope'))).toBe(false);
239+
expect(isRestError(null)).toBe(false);
240+
expect(isRestError(undefined)).toBe(false);
241+
expect(isRestError('string')).toBe(false);
242+
});
243+
244+
it('isGrpcError narrows only GrpcA2AError instances', () => {
245+
expect(isGrpcError(new GrpcTaskNotFoundError({ status: GRPC_STATUS.NOT_FOUND }))).toBe(true);
246+
expect(isGrpcError(new RestTaskNotFoundError())).toBe(false);
247+
expect(isGrpcError(new JsonRpcTaskNotFoundError())).toBe(false);
248+
expect(isGrpcError(new TaskNotFoundError())).toBe(false);
249+
expect(isGrpcError(new Error('nope'))).toBe(false);
250+
});
251+
252+
it('isJsonRpcError narrows only JsonRpcA2AError instances', () => {
253+
expect(isJsonRpcError(new JsonRpcTaskNotFoundError({ envelopeCode: -32001 }))).toBe(true);
254+
expect(isJsonRpcError(new RestTaskNotFoundError())).toBe(false);
255+
expect(isJsonRpcError(new GrpcTaskNotFoundError())).toBe(false);
256+
expect(isJsonRpcError(new TaskNotFoundError())).toBe(false);
257+
expect(isJsonRpcError(new Error('nope'))).toBe(false);
258+
});
259+
260+
it('transports are mutually exclusive: exactly one guard matches per instance', () => {
261+
const rest = new RestTaskNotFoundError({ statusCode: 404 });
262+
const grpc = new GrpcTaskNotFoundError({ status: GRPC_STATUS.NOT_FOUND });
263+
const json = new JsonRpcTaskNotFoundError({ envelopeCode: -32001 });
264+
265+
expect([isRestError(rest), isGrpcError(rest), isJsonRpcError(rest)]).toEqual([
266+
true,
267+
false,
268+
false,
269+
]);
270+
expect([isRestError(grpc), isGrpcError(grpc), isJsonRpcError(grpc)]).toEqual([
271+
false,
272+
true,
273+
false,
274+
]);
275+
expect([isRestError(json), isGrpcError(json), isJsonRpcError(json)]).toEqual([
276+
false,
277+
false,
278+
true,
279+
]);
280+
});
281+
282+
it('guards enable typed access to transport context', () => {
283+
// Compile-time proof (delete the guard and TS complains) + runtime.
284+
const err: A2AError = new RestTaskNotFoundError({
285+
statusCode: 429,
286+
headers: { 'retry-after': '10' },
287+
});
288+
if (isRestError(err)) {
289+
expect(err.statusCode).toBe(429);
290+
expect(err.headers?.['retry-after']).toBe('10');
291+
}
292+
});
293+
});
294+
295+
// ---------------------------------------------------------------------------
296+
// Options plumbing: message defaults, message override, metadata, cause.
297+
// ---------------------------------------------------------------------------
298+
299+
describe('A2AError construction', () => {
300+
it('defaults message from spec.defaultMessage when none is passed', () => {
301+
expect(new TaskNotFoundError().message).toBe('Task not found');
302+
expect(new UnsupportedOperationError().message).toBe('This operation is not supported');
303+
expect(new GenericError().message).toBe('An unexpected error occurred.');
304+
});
305+
306+
it('accepts a bare string as the message (legacy call shape)', () => {
307+
expect(new TaskNotFoundError('custom text').message).toBe('custom text');
308+
});
309+
310+
it('accepts an options object with message', () => {
311+
expect(new TaskNotFoundError({ message: 'via options' }).message).toBe('via options');
312+
});
313+
314+
it('preserves cause (ES2022 Error.cause)', () => {
315+
const root = new Error('root');
316+
const e = new TaskNotFoundError({ message: 'wrapped', cause: root });
317+
expect((e as unknown as { cause: unknown }).cause).toBe(root);
318+
});
319+
320+
it('stores metadata when non-empty and omits it when empty', () => {
321+
const withMd = new TaskNotFoundError({ metadata: { taskId: 't-1' } });
322+
expect(withMd.metadata).toEqual({ taskId: 't-1' });
323+
324+
const withoutMd = new TaskNotFoundError({ metadata: {} });
325+
expect(withoutMd.metadata).toBeUndefined();
326+
327+
const noArg = new TaskNotFoundError();
328+
expect(noArg.metadata).toBeUndefined();
329+
});
330+
331+
it('every semantic error has a corresponding entry in A2A_ERROR_SPECS', () => {
332+
for (const [name, Cls] of Object.entries(A2A_ERROR_CLASSES)) {
333+
const instance = new Cls();
334+
const spec = A2A_ERROR_SPECS[name];
335+
expect(spec).toBeDefined();
336+
expect(instance.code).toBe(spec.code);
337+
expect(instance.reason).toBe(spec.reason);
338+
}
339+
});
340+
});
341+
342+
// ---------------------------------------------------------------------------
343+
// toErrorInfo(): the shape shipped in google.rpc.ErrorInfo (spec §10.6/§11.6).
344+
// ---------------------------------------------------------------------------
345+
346+
describe('A2AError.toErrorInfo', () => {
347+
it('returns spec-shaped ErrorInfo with the right @type and domain', () => {
348+
const info = new TaskNotFoundError().toErrorInfo();
349+
expect(info['@type']).toBe(ERROR_INFO_TYPE);
350+
expect(info.reason).toBe('TASK_NOT_FOUND');
351+
expect(info.domain).toBe(A2A_ERROR_DOMAIN);
352+
expect(info).not.toHaveProperty('metadata'); // omitted when empty
353+
});
354+
355+
it('emits metadata when the constructor received a non-empty map', () => {
356+
const info = new TaskNotFoundError({ metadata: { taskId: 't-1' } }).toErrorInfo();
357+
expect(info.metadata).toEqual({ taskId: 't-1' });
358+
});
359+
});
360+
361+
// ---------------------------------------------------------------------------
362+
// Status helpers: restStatusFor / grpcStatusFor honor the instance override
363+
// but fall back to the semantic spec, and default to UNKNOWN/500 otherwise.
364+
// ---------------------------------------------------------------------------
365+
366+
describe('restStatusFor', () => {
367+
it('returns the instance-level statusCode when it is a RestA2AError', () => {
368+
expect(restStatusFor(new RestTaskNotFoundError({ statusCode: 418 }))).toBe(418);
369+
});
370+
371+
it('falls back to the spec httpStatus for a plain semantic error', () => {
372+
expect(restStatusFor(new TaskNotFoundError())).toBe(HTTP_STATUS.NOT_FOUND);
373+
expect(restStatusFor(new UnsupportedOperationError())).toBe(HTTP_STATUS.BAD_REQUEST);
374+
expect(restStatusFor(new InvalidAgentResponseError())).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
375+
});
376+
377+
it('returns 500 for non-A2A throwables', () => {
378+
expect(restStatusFor(new Error('unrelated'))).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
379+
expect(restStatusFor('string')).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
380+
expect(restStatusFor(undefined)).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
381+
});
382+
});
383+
384+
describe('grpcStatusFor', () => {
385+
it('returns the instance-level status when it is a GrpcA2AError', () => {
386+
expect(grpcStatusFor(new GrpcTaskNotFoundError({ status: GRPC_STATUS.CANCELLED }))).toBe(
387+
GRPC_STATUS.CANCELLED
388+
);
389+
});
390+
391+
it('falls back to the spec grpcStatus for a plain semantic error', () => {
392+
expect(grpcStatusFor(new TaskNotFoundError())).toBe(GRPC_STATUS.NOT_FOUND);
393+
expect(grpcStatusFor(new ContentTypeNotSupportedError())).toBe(GRPC_STATUS.INVALID_ARGUMENT);
394+
expect(grpcStatusFor(new InvalidAgentResponseError())).toBe(GRPC_STATUS.INTERNAL);
395+
});
396+
397+
it('returns UNKNOWN for non-A2A throwables', () => {
398+
expect(grpcStatusFor(new Error('unrelated'))).toBe(GRPC_STATUS.UNKNOWN);
399+
expect(grpcStatusFor(null)).toBe(GRPC_STATUS.UNKNOWN);
400+
});
401+
});
402+
403+
// ---------------------------------------------------------------------------
404+
// Wire roundtrips: serialize semantic error -> parse -> same class + metadata.
405+
// ---------------------------------------------------------------------------
406+
407+
describe('REST roundtrip', () => {
408+
it('semantic error survives toRestErrorBody -> fromRestErrorBody', () => {
409+
const original = new TaskNotFoundError({
410+
message: 'task xyz missing',
411+
metadata: { taskId: 'xyz' },
412+
});
413+
const body = toRestErrorBody(original, HTTP_STATUS.NOT_FOUND);
414+
415+
// §11.6 body shape.
416+
expect(body.error.code).toBe(HTTP_STATUS.NOT_FOUND);
417+
expect(body.error.status).toBe('NOT_FOUND');
418+
expect(body.error.message).toBe('task xyz missing');
419+
expect(body.error.details[0]).toMatchObject({
420+
'@type': ERROR_INFO_TYPE,
421+
reason: 'TASK_NOT_FOUND',
422+
domain: A2A_ERROR_DOMAIN,
423+
metadata: { taskId: 'xyz' },
424+
});
425+
426+
const rebuilt = fromRestErrorBody(body.error, {
427+
statusCode: HTTP_STATUS.NOT_FOUND,
428+
headers: { 'x-a': '1' },
429+
});
430+
431+
expect(rebuilt).toBeInstanceOf(TaskNotFoundError);
432+
expect(rebuilt).toBeInstanceOf(RestTaskNotFoundError);
433+
expect(rebuilt.statusCode).toBe(HTTP_STATUS.NOT_FOUND);
434+
expect(rebuilt.headers?.['x-a']).toBe('1');
435+
expect(rebuilt.metadata).toEqual({ taskId: 'xyz' });
436+
expect(rebuilt.message).toBe('task xyz missing');
437+
});
438+
439+
it('body without ErrorInfo detail becomes RestGenericError', () => {
440+
const rebuilt = fromRestErrorBody({ message: 'plain 500', details: [] }, { statusCode: 500 });
441+
expect(rebuilt).toBeInstanceOf(A2AError);
442+
expect(isRestError(rebuilt)).toBe(true);
443+
expect(rebuilt.name).toBe('GenericError');
444+
expect(rebuilt.message).toBe('plain 500');
445+
expect(rebuilt.statusCode).toBe(500);
446+
});
447+
448+
it('body with unknown ErrorInfo.reason becomes RestGenericError', () => {
449+
const rebuilt = fromRestErrorBody(
450+
{
451+
message: 'unknown',
452+
details: [{ '@type': ERROR_INFO_TYPE, reason: 'MADE_UP_REASON', domain: 'nope' }],
453+
},
454+
{ statusCode: 500 }
455+
);
456+
expect(rebuilt.name).toBe('GenericError');
457+
});
458+
459+
it('ignores metadata when the ErrorInfo.domain is not a2a-protocol.org', () => {
460+
const rebuilt = fromRestErrorBody(
461+
{
462+
message: 'foreign',
463+
details: [
464+
{
465+
'@type': ERROR_INFO_TYPE,
466+
reason: 'TASK_NOT_FOUND',
467+
domain: 'other.example',
468+
metadata: { taskId: 't-1' },
469+
},
470+
],
471+
},
472+
{ statusCode: 404 }
473+
);
474+
expect(rebuilt).toBeInstanceOf(TaskNotFoundError);
475+
expect(rebuilt.metadata).toBeUndefined();
476+
});
477+
});
478+
479+
describe('JSON-RPC roundtrip', () => {
480+
it('semantic error survives toJsonRpcError -> fromJsonRpcErrorResponse', () => {
481+
const original = new TaskNotFoundError({
482+
message: 'task xyz missing',
483+
metadata: { taskId: 'xyz' },
484+
});
485+
const envelopeError = toJsonRpcError(original);
486+
487+
expect(envelopeError.code).toBe(A2A_ERROR_CODE.TASK_NOT_FOUND);
488+
expect(envelopeError.message).toBe('task xyz missing');
489+
expect(envelopeError.data?.[0]).toMatchObject({
490+
'@type': ERROR_INFO_TYPE,
491+
reason: 'TASK_NOT_FOUND',
492+
domain: A2A_ERROR_DOMAIN,
493+
metadata: { taskId: 'xyz' },
494+
});
495+
496+
const rebuilt = mapJsonRpcErrorToSdkError({
497+
jsonrpc: '2.0',
498+
id: 1,
499+
error: envelopeError,
500+
});
501+
expect(rebuilt).toBeInstanceOf(TaskNotFoundError);
502+
expect(rebuilt).toBeInstanceOf(JsonRpcTaskNotFoundError);
503+
expect(rebuilt.envelopeCode).toBe(A2A_ERROR_CODE.TASK_NOT_FOUND);
504+
expect(rebuilt.message).toBe('task xyz missing');
505+
});
506+
507+
it('JsonRpc*Error.envelopeCode overrides the semantic default', () => {
508+
// v0.3 compat case: METHOD_NOT_FOUND has no semantic twin, so we
509+
// route it through JsonRpcRequestMalformedError with envelopeCode
510+
// overridden. The envelope must preserve that wire code.
511+
const err = new JsonRpcRequestMalformedError({
512+
message: 'no such method',
513+
envelopeCode: A2A_ERROR_CODE.METHOD_NOT_FOUND,
514+
});
515+
const envelope = toJsonRpcError(err);
516+
expect(envelope.code).toBe(A2A_ERROR_CODE.METHOD_NOT_FOUND); // NOT -32602
517+
expect(envelope.message).toBe('no such method');
518+
});
519+
520+
it('unknown code becomes JsonRpcTransportError carrying the full envelope', () => {
521+
const envelope: JSONRPCErrorResponse = {
522+
jsonrpc: '2.0',
523+
id: 9,
524+
error: { code: -99999, message: 'mystery', data: { foo: 'bar' } },
525+
};
526+
const rebuilt = mapJsonRpcErrorToSdkError(envelope);
527+
expect(rebuilt).toBeInstanceOf(JSONRPCTransportError);
528+
expect((rebuilt as JSONRPCTransportError).errorResponse).toBe(envelope);
529+
expect(isJsonRpcError(rebuilt)).toBe(true);
530+
});
531+
});

0 commit comments

Comments
 (0)