import { AsyncAPIDocumentInterface, Diagnostic, fromFile, Parser, } from '@asyncapi/parser'; import * as fs from 'fs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MessageDiffOptions } from './message-diff-options'; import { MessageDiff } from './msg-diff'; const resources = `${__dirname}/test-resources`; describe('sanity', () => { const options: MessageDiffOptions = { inputDir: resources, filePattern: '.*(-asyncapi).(json|yaml|yml)', excludePattern: undefined, branch: 'xxx', verbose: false, }; let msgDiff: MessageDiff; let consoleSpy: ReturnType; const loadDocument = async ( file: string, editJson?: (any) => void, ): Promise => { let document: AsyncAPIDocumentInterface | undefined; let diagnostics: Diagnostic[] | undefined; const parser = new Parser(); ({ document, diagnostics } = await fromFile(parser, file).parse()); if (document === undefined) { throw new Error(`Failed to parse ${file}: ${diagnostics}`); } if (!editJson) { return document; } const json = document.json(); editJson(json); ({ document, diagnostics } = await parser.parse(JSON.stringify(json))); if (document === undefined) { throw new Error(`Failed to parse edited JSON: ${diagnostics}`); } return document; }; beforeEach(() => { consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); msgDiff = new MessageDiff(options); }); afterEach(() => { vi.restoreAllMocks(); }); describe('walk', () => { it('resources sanity', async () => { console.log({ resources }); // Assert expect(fs.existsSync(`${resources}/0/1-asyncapi.yml`)).toBeTrue(); expect(fs.existsSync(`${resources}/0/event.json`)).toBeTrue(); }); it('compare with self -> same', async () => { // Act await msgDiff.walk(`${resources}/0`, `${resources}/0`); // Assert expect(msgDiff.results).toEqual({ skipped: 0, same: 6, changed: 0, breaking: 0, }); }); it('remove file -> breaking', async () => { // Act await msgDiff.walk(`${resources}/0`, `${resources}/1`); // Assert expect(msgDiff.results).toEqual({ skipped: 0, same: 1, changed: 0, breaking: 2, }); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( '❌ File not found. This document may have been removed. Assuming this is a breaking change.', ), ); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining(' ✖ remove /channels/cmd-channel'), ); }); it('add file -> ignored', async () => { // Act await msgDiff.walk(`${resources}/1`, `${resources}/0`); // Assert expect(msgDiff.results).toEqual({ skipped: 0, same: 1, changed: 1, breaking: 0, }); }); }); describe('documentDiff', () => { it('remove channel -> breaking', async () => { // Arrange const document1 = await loadDocument(`${resources}/0/1-asyncapi.yml`); const document2 = await loadDocument( `${resources}/0/1-asyncapi.yml`, (json) => { // remove a channel delete json.channels['evt-channel']; }, ); // Act const result = await msgDiff.documentDiff(document1, document2); // Assert expect(result).toBe('breaking'); }); it('remove event -> breaking', async () => { // Arrange const document1 = await loadDocument(`${resources}/0/1-asyncapi.yml`); const document2 = await loadDocument( `${resources}/0/1-asyncapi.yml`, (json) => { // remove an event delete json.channels['evt-channel']?.publish?.message; }, ); // Act const result = await msgDiff.documentDiff(document1, document2); // Assert expect(result).toBe('breaking'); }); it('rename channel -> breaking', async () => { // Arrange const document1 = await loadDocument(`${resources}/0/1-asyncapi.yml`); const document2 = await loadDocument( `${resources}/0/1-asyncapi.yml`, (json) => { // rename a channel json.channels['evt-channel-CHANGE'] = json.channels['evt-channel']; delete json.channels['evt-channel']; }, ); // Act const result = await msgDiff.documentDiff(document1, document2); // Assert expect(result).toBe('breaking'); }); it('rename queue -> breaking', async () => { // Arrange const document1 = await loadDocument(`${resources}/0/1-asyncapi.yml`); const document2 = await loadDocument( `${resources}/0/1-asyncapi.yml`, (json) => { // rename a queue json.channels['evt-channel'].bindings.amqp.queue.name = 'differentqueue'; }, ); // Act const result = await msgDiff.documentDiff(document1, document2); // Assert expect(result).toBe('breaking'); }); it('add channel -> changed', async () => { // Arrange const document1 = await loadDocument(`${resources}/0/1-asyncapi.yml`); const document2 = await loadDocument( `${resources}/0/1-asyncapi.yml`, (json) => { // create a new json.channels['evt-channel-NEW'] = json.channels['evt-channel']; }, ); // Act const result = await msgDiff.documentDiff(document1, document2); // Assert expect(result).toBe('changed'); }); it('change info -> changed', async () => { // Arrange const document1 = await loadDocument(`${resources}/0/1-asyncapi.yml`); const document2 = await loadDocument( `${resources}/0/1-asyncapi.yml`, (json) => { // non breaking changes json.info.title = 'My new title'; json.info.description = 'My new description'; }, ); // Act const result = await msgDiff.documentDiff(document1, document2); // Assert expect(result).toBe('changed'); }); it('change x-service-id -> breaking', async () => { // Arrange const document1 = await loadDocument(`${resources}/0/1-asyncapi.yml`); const document2 = await loadDocument( `${resources}/0/1-asyncapi.yml`, (json) => { // breaking change json['x-service-id'] = 'my-new-service-id'; }, ); // Act const result = await msgDiff.documentDiff(document1, document2); // Assert expect(result).toBe('breaking'); }); }); describe('payloadDiff', () => { it('unsupported schema version -> skipped', async () => { // Arrange const document1 = await loadDocument( `${resources}/0/1-asyncapi.yml`, (json) => { const schema = json.channels['evt-channel'].publish.message.payload; schema.$schema = 'http://json-schema.org/draft-04/schema#'; }, ); const document2 = await loadDocument(`${resources}/0/1-asyncapi.yml`); // Act const result = await msgDiff.payloadDiff( document1.channels().get('evt-channel'), document2.channels().get('evt-channel'), ); // Assert expect(result).toBe('skipped'); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( 'WARNING: Unsupported JsonSchema version. This payload will be skipped.', ), ); }); it('add required to a field -> breaking', async () => { // Arrange const document1 = await loadDocument(`${resources}/0/1-asyncapi.yml`); const document2 = await loadDocument( `${resources}/0/1-asyncapi.yml`, (json) => { const schema = json.channels['evt-channel'].publish.message.payload; schema.required.push('is_public'); }, ); // Act const result = await msgDiff.payloadDiff( document1.channels().get('evt-channel'), document2.channels().get('evt-channel'), ); // Assert expect(result).toBe('breaking'); }); it('remove required from a field -> changed', async () => { // Arrange const document1 = await loadDocument(`${resources}/0/1-asyncapi.yml`); const document2 = await loadDocument( `${resources}/0/1-asyncapi.yml`, (json) => { const schema = json.channels['evt-channel'].publish.message.payload; schema.required = []; }, ); const channel1 = document1.channels().get('evt-channel'); const channel2 = document2.channels().get('evt-channel'); // Act const result = await msgDiff.payloadDiff(channel1, channel2); // Assert expect(result).toBe('changed'); }); it('remove a non required field -> breaking', async () => { // Arrange const document1 = await loadDocument(`${resources}/0/1-asyncapi.yml`); const document2 = await loadDocument( `${resources}/0/1-asyncapi.yml`, (json) => { const schema = json.channels['evt-channel'].publish.message.payload; delete schema.properties.is_public; }, ); // Act const result = await msgDiff.payloadDiff( document1.channels().get('evt-channel'), document2.channels().get('evt-channel'), ); // Assert expect(result).toBe('breaking'); }); it('add a non required field -> changed', async () => { // Arrange const document1 = await loadDocument(`${resources}/0/1-asyncapi.yml`); const document2 = await loadDocument( `${resources}/0/1-asyncapi.yml`, (json) => { const schema = json.channels['evt-channel'].publish.message.payload; schema.properties.new_field = { description: 'blah', type: 'boolean', }; }, ); // Act const result = await msgDiff.payloadDiff( document1.channels().get('evt-channel'), document2.channels().get('evt-channel'), ); // Assert expect(result).toBe('changed'); }); it('extending type of a field -> changed', async () => { // Arrange const document1 = await loadDocument(`${resources}/0/1-asyncapi.yml`); const document2 = await loadDocument( `${resources}/0/1-asyncapi.yml`, (json) => { const schema = json.channels['evt-channel'].publish.message.payload; schema.properties.is_public.type = ['boolean', 'string']; }, ); // Act const result = await msgDiff.payloadDiff( document1.channels().get('evt-channel'), document2.channels().get('evt-channel'), ); // Assert expect(result).toBe('changed'); }); it('reducing type of a field -> breaking', async () => { // Arrange const document1 = await loadDocument( `${resources}/0/1-asyncapi.yml`, (json) => { const schema = json.channels['evt-channel'].publish.message.payload; schema.properties.is_public.type = ['boolean', 'string']; }, ); const document2 = await loadDocument(`${resources}/0/1-asyncapi.yml`); // Act const result = await msgDiff.payloadDiff( document1.channels().get('evt-channel'), document2.channels().get('evt-channel'), ); // Assert expect(result).toBe('breaking'); }); it('changing field description -> same', async () => { // Arrange const document1 = await loadDocument(`${resources}/0/1-asyncapi.yml`); const document2 = await loadDocument( `${resources}/0/1-asyncapi.yml`, (json) => { const schema = json.channels['evt-channel'].publish.message.payload; schema.properties.is_public.description = 'My new description'; }, ); // Act const result = await msgDiff.payloadDiff( document1.channels().get('evt-channel'), document2.channels().get('evt-channel'), ); // Assert expect(result).toBe('same'); }); }); });