Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions src/commands/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { defaultArgs } from '@/args/default.ts'
import { resolveConfig } from '@/config.ts'
import { diffComponent } from '@/utils/api-diff.ts'
import { logError } from '@/utils/error.ts'
import { loadVersionMetaData } from '@/utils/loader.ts'
import { output } from '@/utils/output.ts'
import { resolveVersion } from '@/utils/version.ts'

Expand Down Expand Up @@ -144,11 +143,9 @@ export default defineCommand({
const config = resolveConfig(args)

try {
const [fromSnapshot, toSnapshot] = await Promise.all([
resolveVersion({ ...config, version: args.from }).then(loadVersionMetaData),
resolveVersion({ ...config, version: args.to }).then(loadVersionMetaData),
])
const result = diffComponent(fromSnapshot, toSnapshot, args.component)
const v1 = await resolveVersion({ ...config, version: args.from })
const v2 = await resolveVersion({ ...config, version: args.to })
const result = await diffComponent(v1, v2, args.component)

output({
json: result,
Expand Down
13 changes: 7 additions & 6 deletions src/utils/api-diff.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { ChangedApiItem, ComponentDiff, ComponentDiffResult, FlatApiItem } from '#/changelog.ts'
import type { ChangelogFile, ComponentRecord } from '#/components.ts'
import type { ComponentRecord } from '#/components.ts'
import type { ResolvedVersion } from '@/types.ts'
import {
compareDiffItems,
flattenComponent,
getChangedFields,
toDiffApiItem,
} from '@/utils/api-diff-normalize.ts'
import { matchRenamedItems } from '@/utils/api-diff-rename.ts'
import { loadVersionMetaData } from '@/utils/loader.ts'

function getItemKey(item: FlatApiItem): string {
return `${item.scope}\0${item.category}\0${item.identity}`
Expand Down Expand Up @@ -118,11 +120,10 @@ function findComponent(components: ComponentRecord[], name: string): ComponentRe
return components.find(component => component.name.toLowerCase() === normalizedName)
}

export function diffComponent(
fromSnapshot: ChangelogFile,
toSnapshot: ChangelogFile,
component?: string,
): ComponentDiffResult {
export async function diffComponent(v1: ResolvedVersion, v2: ResolvedVersion, component?: string): Promise<ComponentDiffResult> {
const fromSnapshot = await loadVersionMetaData(v1)
const toSnapshot = await loadVersionMetaData(v2)

const componentNames = component
? [component]
: [...new Set([
Expand Down
27 changes: 15 additions & 12 deletions test/changelog-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,25 @@ afterEach(() => {
})

describe('changelog command', () => {
it('starts loading both snapshots before either one resolves', async () => {
const pending = new Map<string, (snapshot: ChangelogFile) => void>()
mockedLoadVersionMetaData.mockImplementation((version) => {
return new Promise(resolve => pending.set(version.version, resolve))
})
it('resolves both versions and loads their snapshots', async () => {
mockedLoadVersionMetaData.mockImplementation(async version => emptySnapshot(version.version))
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const execution = runCommand(changelogCommand, {

await runCommand(changelogCommand, {
rawArgs: ['1.0.5', '1.5.2', '--format', 'json'],
})

await vi.waitFor(() => expect(mockedLoadVersionMetaData).toHaveBeenCalledTimes(2))
expect([...pending.keys()]).toEqual(['1.0.5', '1.5.2'])

pending.get('1.0.5')!(emptySnapshot('1.0.5'))
pending.get('1.5.2')!(emptySnapshot('1.5.2'))
await execution
expect(mockedResolveVersion).toHaveBeenCalledTimes(2)
expect(mockedResolveVersion).toHaveBeenNthCalledWith(1, expect.objectContaining({ version: '1.0.5' }))
expect(mockedResolveVersion).toHaveBeenNthCalledWith(2, expect.objectContaining({ version: '1.5.2' }))
expect(mockedLoadVersionMetaData).toHaveBeenNthCalledWith(1, {
version: '1.0.5',
majorVersion: 'v1',
})
expect(mockedLoadVersionMetaData).toHaveBeenNthCalledWith(2, {
version: '1.5.2',
majorVersion: 'v1',
})

expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify({
from: '1.0.5',
Expand Down
83 changes: 58 additions & 25 deletions test/changelog.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import type { ResolvedVersion } from '../src/types.ts'
import type { ComponentDiff } from '../src/types/changelog'
import type { ChangelogFile, ComponentApiItemRecord, ComponentApiRecord, ComponentRecord } from '../src/types/components.ts'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { diffComponent } from '../src/utils/api-diff.ts'
import { loadVersionMetaData } from '../src/utils/loader.ts'

vi.mock('../src/utils/loader.ts', () => ({
loadVersionMetaData: vi.fn(),
}))

const mockedLoadVersionMetaData = vi.mocked(loadVersionMetaData)

function apiItem(
name: string,
type = 'string',
Expand Down Expand Up @@ -66,8 +73,31 @@ function snapshot(version: string, components: ComponentRecord[]): ChangelogFile
}
}

function resolvedVersion(version: string): ResolvedVersion {
return {
version,
majorVersion: `v${version.split('.')[0]}`,
}
}

async function diffSnapshots(
from: ChangelogFile,
to: ChangelogFile,
component?: string,
) {
mockedLoadVersionMetaData
.mockResolvedValueOnce(from)
.mockResolvedValueOnce(to)

return await diffComponent(
resolvedVersion(from.version),
resolvedVersion(to.version),
component,
)
}

describe('diffComponent', () => {
it('returns added, removed, and changed API items in the requested shape', () => {
it('returns added, removed, and changed API items in the requested shape', async () => {
const from = snapshot('1.0.5', [
component('Button', apiRecord({
properties: [
Expand All @@ -85,7 +115,7 @@ describe('diffComponent', () => {
})),
])

expect(diffComponent(from, to, 'button')).toEqual({
await expect(diffSnapshots(from, to, 'button')).resolves.toEqual({
from: '1.0.5',
to: '1.5.2',
diffs: [{
Expand Down Expand Up @@ -113,10 +143,10 @@ describe('diffComponent', () => {
})
})

it('treats markdown strikethrough as a deprecation change', () => {
it('treats markdown strikethrough as a deprecation change', async () => {
const fromItem = apiItem('rootStyle', 'CSSProperties', '-', 'Style on the root element')
const toItem = apiItem('~~rootStyle~~', 'CSSProperties', '-', 'Deprecated. Use `styles.root` instead')
const result = diffComponent(
const result = await diffSnapshots(
snapshot('1.0.5', [component('Tree', apiRecord({ properties: [fromItem] }))]),
snapshot('1.5.2', [component('Tree', apiRecord({ properties: [toItem] }))]),
'Tree',
Expand All @@ -135,10 +165,10 @@ describe('diffComponent', () => {
})
})

it('compares method signatures under the same method identity', () => {
it('compares method signatures under the same method identity', async () => {
const fromItem = apiItem('scrollTo(&#123; key: Key &#125;)', '')
const toItem = apiItem('scrollTo(&#123; key: Key, autoExpand?: boolean &#125;)', '')
const result = diffComponent(
const result = await diffSnapshots(
snapshot('1.0.5', [component('Tree', apiRecord({ methods: [fromItem] }))]),
snapshot('1.5.2', [component('Tree', apiRecord({ methods: [toItem] }))]),
'Tree',
Expand All @@ -155,15 +185,15 @@ describe('diffComponent', () => {
}])
})

it('moves high-confidence replacements into changed as renamed', () => {
it('moves high-confidence replacements into changed as renamed', async () => {
const fromItem = apiItem(
'dropdownRender',
'(originNode: VueNode) => VueNode',
'-',
'Deprecated. Use `popupRender` instead',
)
const toItem = apiItem('popupRender', '(originNode: VueNode) => VueNode')
const result = diffComponent(
const result = await diffSnapshots(
snapshot('1.0.5', [component('Select', apiRecord({ properties: [fromItem] }))]),
snapshot('1.5.2', [component('Select', apiRecord({ properties: [toItem] }))]),
'Select',
Expand All @@ -183,10 +213,10 @@ describe('diffComponent', () => {
})
})

it('uses Levenshtein similarity for structurally compatible renames', () => {
it('uses Levenshtein similarity for structurally compatible renames', async () => {
const fromItem = apiItem('popupRenderer', '() => VueNode')
const toItem = apiItem('popupRender', '() => VueNode')
const result = diffComponent(
const result = await diffSnapshots(
snapshot('1.0.5', [component('Select', apiRecord({ properties: [fromItem] }))]),
snapshot('1.5.2', [component('Select', apiRecord({ properties: [toItem] }))]),
'Select',
Expand All @@ -199,8 +229,8 @@ describe('diffComponent', () => {
}])
})

it('keeps ambiguous rename candidates as added and removed', () => {
const result = diffComponent(
it('keeps ambiguous rename candidates as added and removed', async () => {
const result = await diffSnapshots(
snapshot('1.0.5', [component('Select', apiRecord({
properties: [apiItem('optionLabel', 'string')],
}))]),
Expand All @@ -223,8 +253,8 @@ describe('diffComponent', () => {
})
})

it('does not report description-only changes', () => {
const result = diffComponent(
it('does not report description-only changes', async () => {
const result = await diffSnapshots(
snapshot('1.0.5', [component('Button', apiRecord({
properties: [apiItem('disabled', 'boolean', 'false', 'Old description')],
}))]),
Expand All @@ -237,9 +267,9 @@ describe('diffComponent', () => {
expect(result.diffs).toEqual([])
})

it('includes a component that only exists in one snapshot', () => {
it('includes a component that only exists in one snapshot', async () => {
const added = apiItem('value', 'string')
const result = diffComponent(
const result = await diffSnapshots(
snapshot('1.0.5', []),
snapshot('1.5.2', [component('NewComponent', apiRecord({ properties: [added] }))]),
'NewComponent',
Expand All @@ -253,7 +283,7 @@ describe('diffComponent', () => {
}])
})

it('compares every component when no component is provided', () => {
it('compares every component when no component is provided', async () => {
const from = snapshot('1.0.5', [
component('Button', apiRecord({ properties: [apiItem('legacy', 'boolean')] })),
component('Input', apiRecord({ properties: [apiItem('value')] })),
Expand All @@ -264,7 +294,7 @@ describe('diffComponent', () => {
component('Select', apiRecord({ properties: [apiItem('options', 'object[]')] })),
])

const result = diffComponent(from, to)
const result = await diffSnapshots(from, to)

expect(result.diffs.map((diff: ComponentDiff) => diff.component)).toEqual([
'Button',
Expand All @@ -280,8 +310,8 @@ describe('diffComponent', () => {
})
})

it('includes sub-component scope in API identity', () => {
const result = diffComponent(
it('includes sub-component scope in API identity', async () => {
const result = await diffSnapshots(
snapshot('1.0.5', [component('Select', apiRecord(), {
Option: apiRecord(),
})]),
Expand All @@ -298,16 +328,19 @@ describe('diffComponent', () => {
})
})

it('throws when a requested component exists in neither snapshot', () => {
expect(() => diffComponent(
it('throws when a requested component exists in neither snapshot', async () => {
await expect(diffSnapshots(
snapshot('1.0.5', []),
snapshot('1.5.2', []),
'Missing',
)).toThrow('Component Missing not found')
)).rejects.toThrow('Component Missing not found')
})

it('throws a clear error when a version major does not exist', async () => {
await expect(loadVersionMetaData({
const { loadVersionMetaData: actualLoadVersionMetaData }
= await vi.importActual<typeof import('../src/utils/loader.ts')>('../src/utils/loader.ts')

await expect(actualLoadVersionMetaData({
version: '9.0.0',
majorVersion: 'v9',
}, join(import.meta.dirname, '..', 'data'))).rejects.toThrow('v9.0.0 not found')
Expand Down
Loading