also looking at this
fix(ci): enforce dependency update boundaries
#311
4 files
+163
-28
| @@ -0,0 +1,75 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { readFileSync } from 'node:fs' | |
| 3 | import { join } from 'node:path' | |
| 4 | import { groupUpdates } from 'buddy-bot' | |
| 5 | import { runUpdates, selectUpdates, updateOptions } from './buddy-update' | |
| 6 | ||
| 7 | const update = (name: string, currentVersion: string, newVersion: string) => ({ name, currentVersion, newVersion, updateType: 'patch', file: 'package.json', dependencyType: 'dependencies' }) | |
| 8 | const candidates = [update('patch', '^1.2.3', '1.2.4'), update('minor', '~1.2.3', '1.3.0'), update('major', '1.2.3', '2.0.0')] | |
| 9 | ||
| 10 | test('strategy bounds use versions even when every candidate is labelled patch', () => { | |
| 11 | for (const [strategy, names] of Object.entries({ patch: ['patch'], minor: ['patch', 'minor'], major: ['major'], all: ['patch', 'minor', 'major'] })) { | |
| 12 | const selected = selectUpdates(candidates, updateOptions({ BUDDY_STRATEGY: strategy })) | |
| 13 | expect(selected.map(item => item.name)).toEqual(names) | |
| 14 | expect(selected.map(item => item.updateType)).toEqual(names) | |
| 15 | } | |
| 16 | }) | |
| 17 | ||
| 18 | test('package selection is exact and intersects the version strategy', () => { | |
| 19 | const options = updateOptions({ BUDDY_PACKAGES: ' patch, major, patch ' }) | |
| 20 | expect(selectUpdates(candidates, options).map(item => item.name)).toEqual(['patch']) | |
| 21 | expect(selectUpdates(candidates, updateOptions({ BUDDY_PACKAGES: 'pat' }))).toEqual([]) | |
| 22 | }) | |
| 23 | ||
| 24 | test('pinned, ambiguous, prerelease, same-version and downgrade candidates are retained unchanged', () => { | |
| 25 | for (const [before, after] of [ | |
| 26 | ['235036fa0f48bae99b2293df5a3dc35c809b1777', '0.11.62'], | |
| 27 | ['sha256:abc', '2.0.0'], ['v4', 'v5'], ['^3.0.0 || ^4.0.0', '5.57.1'], | |
| 28 | ['workspace:*', '1.0.0'], ['latest', '1.0.0'], ['1.0.0-beta.1', '1.0.0'], | |
| 29 | ['1.0.0', '1.0.0-beta.2'], ['1.0.0', '1.0.0'], ['2.0.0', '1.9.9'], | |
| 30 | ['1.2.3', '1.2.2'], ['9007199254740992.0.0', '9007199254740993.0.0'], | |
| 31 | ['01.2.3', '1.2.4'], | |
| 32 | ]) { | |
| 33 | expect(selectUpdates([update('candidate', before, after)], updateOptions({ BUDDY_STRATEGY: 'all' }))).toEqual([]) | |
| 34 | } | |
| 35 | expect(selectUpdates([update('safe', 'v1.2.3', 'v1.2.4')], updateOptions({}))).toHaveLength(1) | |
| 36 | }) | |
| 37 | ||
| 38 | test('dry run scans but never creates PRs; apply replaces unfiltered groups', async () => { | |
| 39 | let calls = 0 | |
| 40 | const created: any[] = [] | |
| 41 | const buddy = { | |
| 42 | scanForUpdates: async () => { calls++; return { updates: candidates, groups: [{ updates: candidates }] } }, | |
| 43 | createPullRequests: async (scan: any) => { created.push(scan) }, | |
| 44 | } | |
| 45 | const preview = await runUpdates(buddy, groupUpdates, updateOptions({})) | |
| 46 | expect(calls).toBe(1) | |
| 47 | expect(created).toEqual([]) | |
| 48 | expect(preview.groups.flatMap(group => group.updates).map(item => item.name)).toEqual(['patch']) | |
| 49 | await runUpdates(buddy, groupUpdates, updateOptions({ BUDDY_DRY_RUN: 'false' })) | |
| 50 | expect(created).toHaveLength(1) | |
| 51 | expect(created[0].updates.map((item: any) => item.name)).toEqual(['patch']) | |
| 52 | expect(created[0].groups.flatMap((group: any) => group.updates).map((item: any) => item.name)).toEqual(['patch']) | |
| 53 | await runUpdates(buddy, groupUpdates, updateOptions({ BUDDY_DRY_RUN: 'false', BUDDY_PACKAGES: 'absent' })) | |
| 54 | expect(created).toHaveLength(1) | |
| 55 | }) | |
| 56 | ||
| 57 | test('invalid options and scanner failures remain visible', async () => { | |
| 58 | expect(() => updateOptions({ BUDDY_STRATEGY: 'typo' })).toThrow() | |
| 59 | expect(() => updateOptions({ BUDDY_DRY_RUN: 'maybe' })).toThrow() | |
| 60 | const neverCreate = async () => { throw new Error('must not create') } | |
| 61 | await expect(runUpdates({ scanForUpdates: async () => { throw new Error('offline') }, createPullRequests: neverCreate }, groupUpdates, updateOptions({}))).rejects.toThrow('offline') | |
| 62 | await expect(runUpdates({ scanForUpdates: async () => ({}), createPullRequests: neverCreate }, groupUpdates, updateOptions({}))).rejects.toThrow('updates array') | |
| 63 | await expect(runUpdates({ scanForUpdates: async () => ({ updates: [{}] }), createPullRequests: neverCreate }, groupUpdates, updateOptions({}))).rejects.toThrow('malformed update') | |
| 64 | await expect(runUpdates({ scanForUpdates: async () => ({ updates: candidates }), createPullRequests: async () => { throw new Error('API failure') } }, groupUpdates, updateOptions({ BUDDY_DRY_RUN: 'false' }))).rejects.toThrow('API failure') | |
| 65 | }) | |
| 66 | ||
| 67 | test('workflow applies the same guard to dry runs and real updates', () => { | |
| 68 | const workflow = Bun.YAML.parse(readFileSync(join(import.meta.dir, '../.github/workflows/buddy-bot.yml'), 'utf8')) as any | |
| 69 | const step = workflow.jobs['dependency-update'].steps.find((step: any) => step.name === 'Run Buddy dependency updates') | |
| 70 | expect(step.run.trim()).toBe('bun scripts/buddy-update.ts') | |
| 71 | expect(step.if).toBeUndefined() | |
| 72 | expect(step.env.BUDDY_STRATEGY).toBe("$") | |
| 73 | expect(step.env.BUDDY_PACKAGES).toBe('$') | |
| 74 | expect(step.env.BUDDY_DRY_RUN).toBe("$") | |
| 75 | }) | |