import { describe, expect, it, vi } from 'vitest' import { createDraftRelease, latestPreviousPublishedDesktopReleaseTag, parseDesktopReleaseTag, truncateReleaseBody } from './create-draft-release.mjs' function release(tag, options = {}) { return { draft: false, tag_name: tag, ...options } } function jsonResponse(body, init = {}) { return { ok: init.ok ?? true, status: init.status ?? 200, statusText: init.statusText ?? 'OK', json: vi.fn(async () => body), text: vi.fn(async () => (typeof body === 'string' ? body : JSON.stringify(body))) } } describe('truncateReleaseBody', () => { it('leaves short release notes unchanged', () => { expect(truncateReleaseBody('short notes', 120_000)).toBe('short notes') }) it('caps long release notes and appends an explanation', () => { const body = truncateReleaseBody('a'.repeat(130_000), 1_000) expect(body).toHaveLength(1_000) expect(body).toContain('Release notes were truncated') }) }) describe('parseDesktopReleaseTag', () => { it('parses stable and rc desktop release tags only', () => { expect(parseDesktopReleaseTag('v1.4.36')).toMatchObject({ tag: 'v1.4.36', major: 1, minor: 4, patch: 36, rc: null }) expect(parseDesktopReleaseTag('v1.4.36-rc.2')).toMatchObject({ tag: 'v1.4.36-rc.2', major: 1, minor: 4, patch: 36, rc: 2 }) expect(parseDesktopReleaseTag('mobile-v0.0.12')).toBeNull() }) }) describe('latestPreviousPublishedDesktopReleaseTag', () => { it('bounds stable notes to the previous stable release when rcs exist', () => { expect( latestPreviousPublishedDesktopReleaseTag( [release('v1.4.35'), release('v1.4.36-rc.0'), release('v1.4.36')], 'v1.4.36' ) ).toBe('v1.4.35') }) it('does not collapse a stable changelog to its rc-to-stable version bump', () => { expect( latestPreviousPublishedDesktopReleaseTag( [ release('v1.4.120'), release('v1.4.121-rc.0'), release('v1.4.121-rc.6'), release('v1.4.121') ], 'v1.4.121' ) ).toBe('v1.4.120') }) it('bounds the first rc notes to the previous stable release', () => { expect( latestPreviousPublishedDesktopReleaseTag( [release('v1.4.35'), release('v1.4.36-rc.0'), release('mobile-v0.0.12')], 'v1.4.36-rc.0' ) ).toBe('v1.4.35') }) it('bounds later rc notes to the prior rc', () => { expect( latestPreviousPublishedDesktopReleaseTag( [release('v1.4.36-rc.0'), release('v1.4.36-rc.1')], 'v1.4.36-rc.1' ) ).toBe('v1.4.36-rc.0') }) it('ignores draft releases as public changelog boundaries', () => { expect( latestPreviousPublishedDesktopReleaseTag( [release('v1.4.35'), release('v1.4.36-rc.0', { draft: true }), release('v1.4.36-rc.1')], 'v1.4.36-rc.1' ) ).toBe('v1.4.35') }) it('returns empty string for the first desktop release when no earlier tag exists', () => { expect( latestPreviousPublishedDesktopReleaseTag( [release('v1.4.36'), release('mobile-v0.0.12')], 'v1.4.36' ) ).toBe('') expect(latestPreviousPublishedDesktopReleaseTag([], 'v1.4.36')).toBe('') }) it('returns empty string when the current tag is not a desktop release tag', () => { expect( latestPreviousPublishedDesktopReleaseTag( [release('v1.4.35'), release('v1.4.36')], 'mobile-v0.0.12' ) ).toBe('') }) }) describe('createDraftRelease', () => { it('creates a draft release with bounded generated notes', async () => { const fetchImpl = vi .fn() .mockResolvedValueOnce(jsonResponse([release('v1.4.35')])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'a'.repeat(130_000) })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true })) await createDraftRelease({ repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', targetCommitish: 'abc123', fetchImpl, log: vi.fn() }) expect(fetchImpl).toHaveBeenNthCalledWith( 1, 'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=1', expect.any(Object) ) expect(fetchImpl).toHaveBeenNthCalledWith( 2, 'https://api.github.com/repos/stablyai/orca/releases/generate-notes', expect.objectContaining({ method: 'POST', body: JSON.stringify({ tag_name: 'v1.4.36', target_commitish: 'v1.4.36', previous_tag_name: 'v1.4.35' }) }) ) expect(fetchImpl).toHaveBeenNthCalledWith( 3, 'https://api.github.com/repos/stablyai/orca/releases', expect.objectContaining({ method: 'POST', body: expect.any(String) }) ) const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body) expect(createBody).toMatchObject({ tag_name: 'v1.4.36', target_commitish: 'abc123', name: 'v1.4.36', draft: true, prerelease: false, make_latest: 'false' }) expect(createBody.body).toHaveLength(120_000) expect(createBody.body).toContain('Release notes were truncated') }) it('marks rc tags as prereleases', async () => { const fetchImpl = vi .fn() .mockResolvedValueOnce(jsonResponse([release('v1.4.36')])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36-rc.1', body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36-rc.1', draft: true })) await createDraftRelease({ repo: 'stablyai/orca', tag: 'v1.4.36-rc.1', token: 'token', targetCommitish: 'abc123', fetchImpl, log: vi.fn() }) const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body) expect(createBody.prerelease).toBe(true) }) it('regenerates notes for an existing draft release', async () => { const fetchImpl = vi .fn() .mockResolvedValueOnce( jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })]) ) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'stale' })) .mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'notes' })) await createDraftRelease({ repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', targetCommitish: 'abc123', fetchImpl, log: vi.fn() }) expect(fetchImpl).toHaveBeenNthCalledWith( 3, 'https://api.github.com/repos/stablyai/orca/releases/42', expect.not.objectContaining({ method: expect.anything() }) ) expect(fetchImpl).toHaveBeenNthCalledWith( 4, 'https://api.github.com/repos/stablyai/orca/releases/42', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ body: 'notes' }) }) ) }) it('skips the update when the draft was published while notes were generated', async () => { const fetchImpl = vi .fn() .mockResolvedValueOnce( jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })]) ) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false })) await createDraftRelease({ repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', targetCommitish: 'abc123', fetchImpl, log: vi.fn() }) expect(fetchImpl).toHaveBeenCalledTimes(3) expect(fetchImpl).toHaveBeenNthCalledWith( 3, 'https://api.github.com/repos/stablyai/orca/releases/42', expect.not.objectContaining({ method: expect.anything() }) ) }) it('restores the published body when publication lands between the check and the patch', async () => { const log = vi.fn() const fetchImpl = vi .fn() .mockResolvedValueOnce( jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })]) ) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'hand-written notes' })) .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'hand-written notes' })) await createDraftRelease({ repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', targetCommitish: 'abc123', fetchImpl, log }) expect(fetchImpl).toHaveBeenCalledTimes(6) expect(fetchImpl).toHaveBeenNthCalledWith( 6, 'https://api.github.com/repos/stablyai/orca/releases/42', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ body: 'hand-written notes' }) }) ) expect(log).toHaveBeenCalledWith(expect.stringContaining('restored its published body')) }) it('leaves a body written after the patch in place instead of rolling it back', async () => { const log = vi.fn() const fetchImpl = vi .fn() .mockResolvedValueOnce( jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })]) ) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'hand-written notes' })) .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'newer published body' })) await createDraftRelease({ repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', targetCommitish: 'abc123', fetchImpl, log }) expect(fetchImpl).toHaveBeenCalledTimes(5) expect(log).toHaveBeenCalledWith(expect.stringContaining('leaving the newer body in place')) }) it('preserves notes on an existing published release', async () => { const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse([release('v1.4.36', { id: 42 })])) await createDraftRelease({ repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', targetCommitish: 'abc123', fetchImpl, log: vi.fn() }) expect(fetchImpl).toHaveBeenCalledTimes(1) }) it('omits previous_tag_name for the first desktop release so notes fall back to the GitHub default', async () => { const fetchImpl = vi .fn() .mockResolvedValueOnce(jsonResponse([release('mobile-v0.0.12')])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true })) await createDraftRelease({ repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', targetCommitish: 'abc123', fetchImpl, log: vi.fn() }) const generateNotesBody = JSON.parse(fetchImpl.mock.calls[1][1].body) expect(generateNotesBody).toEqual({ tag_name: 'v1.4.36', target_commitish: 'v1.4.36' }) expect(generateNotesBody).not.toHaveProperty('previous_tag_name') }) it('paginates through every release page before choosing the previous release', async () => { const firstPage = Array.from({ length: 100 }, (_, index) => release(`mobile-v0.0.${index}`)) const fetchImpl = vi .fn() .mockResolvedValueOnce(jsonResponse(firstPage)) .mockResolvedValueOnce(jsonResponse([release('v1.4.35')])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true })) await createDraftRelease({ repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', targetCommitish: 'abc123', fetchImpl, log: vi.fn() }) expect(fetchImpl).toHaveBeenNthCalledWith( 1, 'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=1', expect.any(Object) ) expect(fetchImpl).toHaveBeenNthCalledWith( 2, 'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=2', expect.any(Object) ) const generateNotesBody = JSON.parse(fetchImpl.mock.calls[2][1].body) expect(generateNotesBody.previous_tag_name).toBe('v1.4.35') }) it('refuses an untagged GitHub draft so electron-builder cannot publish latest', async () => { const fetchImpl = vi .fn() .mockResolvedValueOnce(jsonResponse([])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) .mockResolvedValueOnce( jsonResponse({ tag_name: 'untagged-abc', name: 'v1.4.36', draft: true }) ) await expect( createDraftRelease({ repo: 'stablyai/orca', tag: 'v1.4.36', token: 'token', targetCommitish: 'abc123', fetchImpl, log: vi.fn() }) ).rejects.toThrow('GitHub created draft release untagged-abc instead of draft v1.4.36') }) })