also looking at this
test: cover the untested halves of the big integration files
#1443
4 files
+2344
-83
| @@ -931,21 +931,6 @@ export class RegistryClient { | ||
| 931 | 931 | } |
| 932 | 932 | } |
| 933 | 933 | |
| 934 | /** | |
| 935 | * Extract major version number from a version string | |
| 936 | */ | |
| 937 | private getMajorVersion(version: string): string { | |
| 938 | return version.replace(/^[v^~>=<]+/, '').split('.')[0] || '0' | |
| 939 | } | |
| 940 | ||
| 941 | /** | |
| 942 | * Extract minor version number from a version string | |
| 943 | */ | |
| 944 | private getMinorVersion(version: string): string { | |
| 945 | const parts = version.replace(/^[v^~>=<]+/, '').split('.') | |
| 946 | return parts[1] || '0' | |
| 947 | } | |
| 948 | ||
| 949 | 934 | /** |
| 950 | 935 | * Get outdated Composer packages |
| 951 | 936 | */ |
| @@ -1112,74 +1097,6 @@ export class RegistryClient { | ||
| 1112 | 1097 | } |
| 1113 | 1098 | } |
| 1114 | 1099 | |
| 1115 | /** | |
| 1116 | * Find the best patch, minor, and major updates for a package | |
| 1117 | */ | |
| 1118 | private async findBestUpdates(currentVersion: string, availableVersions: string[], _constraint: string): Promise<{ version: string, type: 'patch' | 'minor' | 'major' }[]> { | |
| 1119 | const { getUpdateType } = await import('../utils/helpers') | |
| 1120 | const candidates: { version: string, type: 'patch' | 'minor' | 'major' }[] = [] | |
| 1121 | ||
| 1122 | // Parse current version | |
| 1123 | const currentParts = this.parseVersion(currentVersion) | |
| 1124 | if (!currentParts) { | |
| 1125 | return [] | |
| 1126 | } | |
| 1127 | ||
| 1128 | let bestPatch: string | null = null | |
| 1129 | let bestMinor: string | null = null | |
| 1130 | let bestMajor: string | null = null | |
| 1131 | ||
| 1132 | for (const version of availableVersions) { | |
| 1133 | // Skip dev/alpha/beta versions for now (could be enhanced later) | |
| 1134 | if (version.includes('dev') || version.includes('alpha') || version.includes('beta') || version.includes('RC')) { | |
| 1135 | continue | |
| 1136 | } | |
| 1137 | ||
| 1138 | const versionParts = this.parseVersion(version) | |
| 1139 | if (!versionParts) { | |
| 1140 | continue | |
| 1141 | } | |
| 1142 | ||
| 1143 | // Skip versions that are not newer | |
| 1144 | const comparison = this.compareVersions(version, currentVersion) | |
| 1145 | if (comparison <= 0) { | |
| 1146 | continue | |
| 1147 | } | |
| 1148 | ||
| 1149 | const updateType = getUpdateType(currentVersion, version) | |
| 1150 | ||
| 1151 | // Find best update for each type | |
| 1152 | if (updateType === 'patch' && versionParts.major === currentParts.major && versionParts.minor === currentParts.minor) { | |
| 1153 | if (!bestPatch || this.compareVersions(version, bestPatch) > 0) { | |
| 1154 | bestPatch = version | |
| 1155 | } | |
| 1156 | } | |
| 1157 | else if (updateType === 'minor' && versionParts.major === currentParts.major) { | |
| 1158 | if (!bestMinor || this.compareVersions(version, bestMinor) > 0) { | |
| 1159 | bestMinor = version | |
| 1160 | } | |
| 1161 | } | |
| 1162 | else if (updateType === 'major') { | |
| 1163 | if (!bestMajor || this.compareVersions(version, bestMajor) > 0) { | |
| 1164 | bestMajor = version | |
| 1165 | } | |
| 1166 | } | |
| 1167 | } | |
| 1168 | ||
| 1169 | // Add the best candidates | |
| 1170 | if (bestPatch) { | |
| 1171 | candidates.push({ version: bestPatch, type: 'patch' }) | |
| 1172 | } | |
| 1173 | if (bestMinor) { | |
| 1174 | candidates.push({ version: bestMinor, type: 'minor' }) | |
| 1175 | } | |
| 1176 | if (bestMajor) { | |
| 1177 | candidates.push({ version: bestMajor, type: 'major' }) | |
| 1178 | } | |
| 1179 | ||
| 1180 | return candidates | |
| 1181 | } | |
| 1182 | ||
| 1183 | 1100 | /** |
| 1184 | 1101 | * Parse a version string into major.minor.patch |
| 1185 | 1102 | */ |
test/buddy-orchestration.test.tsadded+897-0
| @@ -0,0 +1,897 @@ | ||
| 1 | import type { BuddyConfig, Issue, PackageFile, PackageUpdate, PullRequest, UpdateScanResult } from '../src/types' | |
| 2 | import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test' | |
| 3 | import fs from 'node:fs' | |
| 4 | import os from 'node:os' | |
| 5 | import path from 'node:path' | |
| 6 | import process from 'node:process' | |
| 7 | import { Buddy } from '../src/buddy' | |
| 8 | import { serializeManifest } from '../src/pr/pr-manifest' | |
| 9 | ||
| 10 | /** | |
| 11 | * Orchestration-layer coverage for src/buddy.ts: the auto-merge fallback loop, | |
| 12 | * the satisfied-PR auto-close verifier, the dashboard pipeline driven through | |
| 13 | * the real GitHub provider against a stubbed fetch, and the pure grouping and | |
| 14 | * dedup helpers. Everything runs offline — the preload fetch guard stays armed | |
| 15 | * except where a test installs its own stub, and every global touched here | |
| 16 | * (env tokens, fetch, prototype spies) is restored in afterEach. | |
| 17 | */ | |
| 18 | ||
| 19 | const DASHBOARD_MARKER = 'This issue lists Buddy updates and detected dependencies' | |
| 20 | ||
| 21 | const MANAGED_ENV_VARS = [ | |
| 22 | 'GITHUB_TOKEN', | |
| 23 | 'BUDDY_TOKEN', | |
| 24 | 'GH_TOKEN', | |
| 25 | 'GITHUB_REPOSITORY', | |
| 26 | 'GITHUB_API_URL', | |
| 27 | 'CI_PROJECT_PATH', | |
| 28 | 'BITBUCKET_REPO_FULL_NAME', | |
| 29 | ] as const | |
| 30 | ||
| 31 | let savedEnv: Record<string, string | undefined> = {} | |
| 32 | let originalFetch: typeof globalThis.fetch | |
| 33 | const restorers: Array<() => void> = [] | |
| 34 | ||
| 35 | beforeEach(() => { | |
| 36 | savedEnv = {} | |
| 37 | for (const name of MANAGED_ENV_VARS) { | |
| 38 | savedEnv[name] = process.env[name] | |
| 39 | delete process.env[name] | |
| 40 | } | |
| 41 | originalFetch = globalThis.fetch | |
| 42 | }) | |
| 43 | ||
| 44 | afterEach(() => { | |
| 45 | for (const name of MANAGED_ENV_VARS) { | |
| 46 | const value = savedEnv[name] | |
| 47 | if (value === undefined) | |
| 48 | delete process.env[name] | |
| 49 | else | |
| 50 | process.env[name] = value | |
| 51 | } | |
| 52 | globalThis.fetch = originalFetch | |
| 53 | while (restorers.length > 0) { | |
| 54 | const restore = restorers.pop() | |
| 55 | restore?.() | |
| 56 | } | |
| 57 | }) | |
| 58 | ||
| 59 | function makeUpdate(overrides: Partial<PackageUpdate> = {}): PackageUpdate { | |
| 60 | return { | |
| 61 | name: 'lodash', | |
| 62 | currentVersion: '4.17.20', | |
| 63 | newVersion: '4.17.21', | |
| 64 | updateType: 'patch', | |
| 65 | dependencyType: 'dependencies', | |
| 66 | file: 'package.json', | |
| 67 | ...overrides, | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | function makePR(overrides: Partial<PullRequest> = {}): PullRequest { | |
| 72 | return { | |
| 73 | number: 101, | |
| 74 | title: 'chore(deps): update all non-major dependencies', | |
| 75 | body: `updates${serializeManifest([makeUpdate()])}`, | |
| 76 | head: 'buddy/update-non-major', | |
| 77 | base: 'main', | |
| 78 | state: 'open', | |
| 79 | url: 'https://github.com/test-owner/test-repo/pull/101', | |
| 80 | createdAt: new Date(0), | |
| 81 | updatedAt: new Date(0), | |
| 82 | author: 'github-actions[bot]', | |
| 83 | reviewers: [], | |
| 84 | assignees: [], | |
| 85 | labels: ['dependencies'], | |
| 86 | draft: false, | |
| 87 | ...overrides, | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | function makeIssue(overrides: Partial<Issue> = {}): Issue { | |
| 92 | return { | |
| 93 | number: 1, | |
| 94 | title: 'Some issue', | |
| 95 | body: 'body', | |
| 96 | state: 'open', | |
| 97 | url: 'https://github.com/test-owner/test-repo/issues/1', | |
| 98 | createdAt: new Date(0), | |
| 99 | updatedAt: new Date(0), | |
| 100 | author: 'someone', | |
| 101 | assignees: [], | |
| 102 | labels: [], | |
| 103 | ...overrides, | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | function makeScanResult(updates: PackageUpdate[]): UpdateScanResult { | |
| 108 | return { | |
| 109 | totalPackages: updates.length, | |
| 110 | updates, | |
| 111 | groups: [], | |
| 112 | scannedAt: new Date(0), | |
| 113 | duration: 1, | |
| 114 | } | |
| 115 | } | |
| 116 | ||
| 117 | function baseConfig(overrides: Record<string, unknown> = {}): BuddyConfig { | |
| 118 | return { | |
| 119 | logLevel: 'silent', | |
| 120 | repository: { provider: 'github', owner: 'test-owner', name: 'test-repo' }, | |
| 121 | security: { enabled: false }, | |
| 122 | ...overrides, | |
| 123 | } as BuddyConfig | |
| 124 | } | |
| 125 | ||
| 126 | function autoMergeConfig(overrides: Record<string, unknown> = {}): BuddyConfig { | |
| 127 | return baseConfig({ | |
| 128 | pullRequest: { autoMerge: { enabled: true, strategy: 'squash', conditions: ['patch-only'] } }, | |
| 129 | ...overrides, | |
| 130 | }) | |
| 131 | } | |
| 132 | ||
| 133 | /** Spy on the private logger without letting output reach the console. */ | |
| 134 | function loggerSpy(buddy: Buddy, method: 'error' | 'warn'): any { | |
| 135 | const spy = spyOn((buddy as any).logger, method).mockImplementation(() => {}) | |
| 136 | restorers.push(() => spy.mockRestore()) | |
| 137 | return spy | |
| 138 | } | |
| 139 | ||
| 140 | /** Mock the inner scan `checkAndCloseSatisfiedPRs` performs, restorably. */ | |
| 141 | function stubScan(updates: PackageUpdate[]): void { | |
| 142 | const spy = spyOn(Buddy.prototype, 'scanForUpdates').mockResolvedValue(makeScanResult(updates)) | |
| 143 | restorers.push(() => spy.mockRestore()) | |
| 144 | } | |
| 145 | ||
| 146 | interface RecordedCall { | |
| 147 | method: string | |
| 148 | url: string | |
| 149 | body?: string | |
| 150 | } | |
| 151 | ||
| 152 | let fetchCalls: RecordedCall[] = [] | |
| 153 | ||
| 154 | /** | |
| 155 | * Replace the preload fetch guard with a URL-routed stub for one test. | |
| 156 | * | |
| 157 | * Unrouted requests come back as 400 — a non-transient status, so the | |
| 158 | * provider fails fast with the URL in the error instead of retrying. | |
| 159 | */ | |
| 160 | function stubFetch(route: (method: string, url: string) => unknown): void { | |
| 161 | fetchCalls = [] | |
| 162 | globalThis.fetch = (async (input: unknown, init?: RequestInit) => { | |
| 163 | const url = typeof input === 'string' ? input : String((input as { url?: string })?.url ?? input) | |
| 164 | const method = String(init?.method ?? 'GET').toUpperCase() | |
| 165 | fetchCalls.push({ method, url, body: typeof init?.body === 'string' ? init.body : undefined }) | |
| 166 | const payload = route(method, url) | |
| 167 | if (payload === undefined) | |
| 168 | return new Response(JSON.stringify({ message: `Unrouted test request: ${method} ${url}` }), { status: 400 }) | |
| 169 | return new Response(JSON.stringify(payload), { | |
| 170 | status: 200, | |
| 171 | headers: { 'content-type': 'application/json' }, | |
| 172 | }) | |
| 173 | }) as typeof globalThis.fetch | |
| 174 | } | |
| 175 | ||
| 176 | function issueJson(number: number, overrides: Record<string, unknown> = {}): Record<string, unknown> { | |
| 177 | return { | |
| 178 | number, | |
| 179 | title: 'Dependency Dashboard', | |
| 180 | body: 'existing body', | |
| 181 | state: 'open', | |
| 182 | html_url: `https://github.com/test-owner/test-repo/issues/${number}`, | |
| 183 | created_at: '2026-01-01T00:00:00Z', | |
| 184 | updated_at: '2026-01-01T00:00:00Z', | |
| 185 | closed_at: null, | |
| 186 | user: { login: 'github-actions[bot]' }, | |
| 187 | assignees: [], | |
| 188 | labels: [], | |
| 189 | ...overrides, | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | describe('buddy orchestration', () => { | |
| 194 | describe('mergeEligiblePullRequests', () => { | |
| 195 | function makeMergeProvider(prs: PullRequest[], checksState?: string): any { | |
| 196 | const provider: any = { | |
| 197 | capabilities: () => ({ nativeAutoMerge: true }), | |
| 198 | getPullRequests: mock(() => Promise.resolve(prs)), | |
| 199 | mergePullRequest: mock(() => Promise.resolve()), | |
| 200 | } | |
| 201 | if (checksState !== undefined) | |
| 202 | provider.getPullRequestChecksState = mock(() => Promise.resolve(checksState)) | |
| 203 | return provider | |
| 204 | } | |
| 205 | ||
| 206 | it('success case - merges a green patch-only buddy PR and reports its number', async () => { | |
| 207 | const provider = makeMergeProvider([makePR()], 'success') | |
| 208 | const buddy = new Buddy(autoMergeConfig()) | |
| 209 | ;(buddy as any).createGitProvider = async () => provider | |
| 210 | ||
| 211 | const merged = await buddy.mergeEligiblePullRequests() | |
| 212 | ||
| 213 | expect(merged).toEqual([101]) | |
| 214 | expect(provider.mergePullRequest).toHaveBeenCalledWith(101, 'squash') | |
| 215 | }) | |
| 216 | ||
| 217 | it('failure case - refuses a PR whose checks are failing', async () => { | |
| 218 | const provider = makeMergeProvider([makePR()], 'failure') | |
| 219 | const buddy = new Buddy(autoMergeConfig()) | |
| 220 | ;(buddy as any).createGitProvider = async () => provider | |
| 221 | ||
| 222 | expect(await buddy.mergeEligiblePullRequests()).toEqual([]) | |
| 223 | expect(provider.mergePullRequest).not.toHaveBeenCalled() | |
| 224 | }) | |
| 225 | ||
| 226 | it('failure case - refuses a PR whose checks are still pending', async () => { | |
| 227 | const provider = makeMergeProvider([makePR()], 'pending') | |
| 228 | const buddy = new Buddy(autoMergeConfig()) | |
| 229 | ;(buddy as any).createGitProvider = async () => provider | |
| 230 | ||
| 231 | expect(await buddy.mergeEligiblePullRequests()).toEqual([]) | |
| 232 | expect(provider.mergePullRequest).not.toHaveBeenCalled() | |
| 233 | }) | |
| 234 | ||
| 235 | it('edge case - a repository with no checks at all still merges', async () => { | |
| 236 | // No getPullRequestChecksState on the provider means checks === 'none': | |
| 237 | // nothing to wait for, only an explicit failure blocks the merge. | |
| 238 | const provider = makeMergeProvider([makePR()]) | |
| 239 | const buddy = new Buddy(autoMergeConfig()) | |
| 240 | ;(buddy as any).createGitProvider = async () => provider | |
| 241 | ||
| 242 | expect(await buddy.mergeEligiblePullRequests()).toEqual([101]) | |
| 243 | expect(provider.mergePullRequest).toHaveBeenCalledWith(101, 'squash') | |
| 244 | }) | |
| 245 | ||
| 246 | it('success case - dry run lists would-merge PRs without touching the provider', async () => { | |
| 247 | const provider = makeMergeProvider([makePR()], 'success') | |
| 248 | const buddy = new Buddy(autoMergeConfig()) | |
| 249 | ;(buddy as any).createGitProvider = async () => provider | |
| 250 | ||
| 251 | expect(await buddy.mergeEligiblePullRequests(true)).toEqual([101]) | |
| 252 | expect(provider.mergePullRequest).not.toHaveBeenCalled() | |
| 253 | }) | |
| 254 | ||
| 255 | it('failure case - swallows a provider merge failure and continues to the next PR', async () => { | |
| 256 | const prs = [ | |
| 257 | makePR({ number: 101, head: 'buddy/update-a' }), | |
| 258 | makePR({ number: 102, head: 'buddy/update-b' }), | |
| 259 | ] | |
| 260 | const provider = makeMergeProvider(prs, 'success') | |
| 261 | provider.mergePullRequest = mock((prNumber: number) => | |
| 262 | prNumber === 101 ? Promise.reject(new Error('merge conflict')) : Promise.resolve()) | |
| 263 | const buddy = new Buddy(autoMergeConfig()) | |
| 264 | ;(buddy as any).createGitProvider = async () => provider | |
| 265 | loggerSpy(buddy, 'warn') | |
| 266 | ||
| 267 | const merged = await buddy.mergeEligiblePullRequests() | |
| 268 | ||
| 269 | expect(merged).toEqual([102]) | |
| 270 | expect(provider.mergePullRequest).toHaveBeenCalledTimes(2) | |
| 271 | }) | |
| 272 | ||
| 273 | it('edge case - returns empty when auto-merge is disabled, without building a provider', async () => { | |
| 274 | const buddy = new Buddy(baseConfig()) | |
| 275 | const factory = mock(() => Promise.resolve(makeMergeProvider([makePR()], 'success'))) | |
| 276 | ;(buddy as any).createGitProvider = factory | |
| 277 | ||
| 278 | expect(await buddy.mergeEligiblePullRequests()).toEqual([]) | |
| 279 | expect(factory).not.toHaveBeenCalled() | |
| 280 | }) | |
| 281 | ||
| 282 | it('edge case - returns empty when no provider can be built', async () => { | |
| 283 | // Auto-merge enabled but no repository configured and no env tokens: | |
| 284 | // createGitProvider degrades to null and the run reports nothing merged. | |
| 285 | const buddy = new Buddy(autoMergeConfig({ repository: undefined })) | |
| 286 | loggerSpy(buddy, 'warn') | |
| 287 | ||
| 288 | expect(await buddy.mergeEligiblePullRequests()).toEqual([]) | |
| 289 | }) | |
| 290 | }) | |
| 291 | ||
| 292 | describe('queueAutoMerge', () => { | |
| 293 | const pr = { number: 7, title: 't', body: 'b', head: 'buddy/update-x', labels: [], draft: false } | |
| 294 | ||
| 295 | it('failure case - respects the nativeAutoMerge capability gate', async () => { | |
| 296 | const provider: any = { | |
| 297 | capabilities: () => ({ nativeAutoMerge: false }), | |
| 298 | enableAutoMerge: mock(() => Promise.resolve(true)), | |
| 299 | } | |
| 300 | const buddy = new Buddy(autoMergeConfig()) | |
| 301 | ||
| 302 | await (buddy as any).queueAutoMerge(provider, pr, [makeUpdate()], []) | |
| 303 | ||
| 304 | expect(provider.enableAutoMerge).not.toHaveBeenCalled() | |
| 305 | }) | |
| 306 | ||
| 307 | it('edge case - a refused queue request does not throw', async () => { | |
| 308 | const provider: any = { | |
| 309 | capabilities: () => ({ nativeAutoMerge: true }), | |
| 310 | enableAutoMerge: mock(() => Promise.resolve(false)), | |
| 311 | } | |
| 312 | const buddy = new Buddy(autoMergeConfig()) | |
| 313 | ||
| 314 | await (buddy as any).queueAutoMerge(provider, pr, [makeUpdate()], []) | |
| 315 | ||
| 316 | expect(provider.enableAutoMerge).toHaveBeenCalledWith(7, 'squash') | |
| 317 | }) | |
| 318 | ||
| 319 | it('failure case - swallows an enableAutoMerge rejection', async () => { | |
| 320 | // A PR that could not be queued is still a perfectly good PR. | |
| 321 | const provider: any = { | |
| 322 | capabilities: () => ({ nativeAutoMerge: true }), | |
| 323 | enableAutoMerge: mock(() => Promise.reject(new Error('no required checks'))), | |
| 324 | } | |
| 325 | const buddy = new Buddy(autoMergeConfig()) | |
| 326 | loggerSpy(buddy, 'warn') | |
| 327 | ||
| 328 | await (buddy as any).queueAutoMerge(provider, pr, [makeUpdate()], []) | |
| 329 | ||
| 330 | expect(provider.enableAutoMerge).toHaveBeenCalledTimes(1) | |
| 331 | }) | |
| 332 | }) | |
| 333 | ||
| 334 | describe('checkAndCloseSatisfiedPRs', () => { | |
| 335 | function makeCloseProvider(prs: PullRequest[]): any { | |
| 336 | return { | |
| 337 | getPullRequests: mock(() => Promise.resolve(prs)), | |
| 338 | createComment: mock(() => Promise.resolve()), | |
| 339 | closePullRequest: mock(() => Promise.resolve()), | |
| 340 | deleteBranch: mock(() => Promise.resolve()), | |
| 341 | } | |
| 342 | } | |
| 343 | ||
| 344 | function lodashPR(): PullRequest { | |
| 345 | return makePR({ | |
| 346 | number: 123, | |
| 347 | head: 'buddy/update-lodash', | |
| 348 | body: `updates${serializeManifest([makeUpdate({ name: 'lodash', currentVersion: '4.17.20', newVersion: '4.17.21' })])}`, | |
| 349 | }) | |
| 350 | } | |
| 351 | ||
| 352 | it('success case - closes a PR whose every package is verifiably at or beyond target', async () => { | |
| 353 | stubScan([makeUpdate({ name: 'lodash', currentVersion: '4.17.21', newVersion: '4.17.22' })]) | |
| 354 | const provider = makeCloseProvider([lodashPR()]) | |
| 355 | const buddy = new Buddy(baseConfig()) | |
| 356 | ||
| 357 | await buddy.checkAndCloseSatisfiedPRs(provider, false) | |
| 358 | ||
| 359 | expect(provider.createComment).toHaveBeenCalledTimes(1) | |
| 360 | const [prNumber, comment] = provider.createComment.mock.calls[0] | |
| 361 | expect(prNumber).toBe(123) | |
| 362 | expect(comment).toContain('lodash') | |
| 363 | expect(provider.closePullRequest).toHaveBeenCalledWith(123) | |
| 364 | expect(provider.deleteBranch).toHaveBeenCalledWith('buddy/update-lodash') | |
| 365 | }) | |
| 366 | ||
| 367 | it('failure case - never closes on an empty scan (mass-closure guard)', async () => { | |
| 368 | // A scan returning nothing may mean bun outdated failed or the registry | |
| 369 | // rate-limited — closing every PR on that evidence would wipe them out. | |
| 370 | stubScan([]) | |
| 371 | const provider = makeCloseProvider([lodashPR()]) | |
| 372 | const buddy = new Buddy(baseConfig()) | |
| 373 | loggerSpy(buddy, 'warn') | |
| 374 | ||
| 375 | await buddy.checkAndCloseSatisfiedPRs(provider, false) | |
| 376 | ||
| 377 | expect(provider.createComment).not.toHaveBeenCalled() | |
| 378 | expect(provider.closePullRequest).not.toHaveBeenCalled() | |
| 379 | }) | |
| 380 | ||
| 381 | it('failure case - keeps a PR open when its package is absent from the scan (unverifiable)', async () => { | |
| 382 | stubScan([makeUpdate({ name: 'react', currentVersion: '18.0.0', newVersion: '18.2.0' })]) | |
| 383 | const provider = makeCloseProvider([lodashPR()]) | |
| 384 | const buddy = new Buddy(baseConfig()) | |
| 385 | ||
| 386 | await buddy.checkAndCloseSatisfiedPRs(provider, false) | |
| 387 | ||
| 388 | expect(provider.closePullRequest).not.toHaveBeenCalled() | |
| 389 | }) | |
| 390 | ||
| 391 | it('failure case - keeps a PR open when the scan still wants the same target version', async () => { | |
| 392 | stubScan([makeUpdate({ name: 'lodash', currentVersion: '4.17.20', newVersion: '4.17.21' })]) | |
| 393 | const provider = makeCloseProvider([lodashPR()]) | |
| 394 | const buddy = new Buddy(baseConfig()) | |
| 395 | ||
| 396 | await buddy.checkAndCloseSatisfiedPRs(provider, false) | |
| 397 | ||
| 398 | expect(provider.closePullRequest).not.toHaveBeenCalled() | |
| 399 | }) | |
| 400 | ||
| 401 | it('failure case - one unverifiable package keeps a multi-package PR open', async () => { | |
| 402 | const twoPackagePR = makePR({ | |
| 403 | number: 124, | |
| 404 | head: 'buddy/update-two', | |
| 405 | body: `updates${serializeManifest([ | |
| 406 | makeUpdate({ name: 'lodash', currentVersion: '4.17.20', newVersion: '4.17.21' }), | |
| 407 | makeUpdate({ name: 'express', currentVersion: '4.18.0', newVersion: '4.18.2' }), | |
| 408 | ])}`, | |
| 409 | }) | |
| 410 | // lodash is verifiably satisfied, express is absent from the scan: the | |
| 411 | // zero-unverifiable rule must keep the PR open. | |
| 412 | stubScan([makeUpdate({ name: 'lodash', currentVersion: '4.17.21', newVersion: '4.17.22' })]) | |
| 413 | const provider = makeCloseProvider([twoPackagePR]) | |
| 414 | const buddy = new Buddy(baseConfig()) | |
| 415 | ||
| 416 | await buddy.checkAndCloseSatisfiedPRs(provider, false) | |
| 417 | ||
| 418 | expect(provider.closePullRequest).not.toHaveBeenCalled() | |
| 419 | }) | |
| 420 | ||
| 421 | it('edge case - dry run counts closures without performing them', async () => { | |
| 422 | stubScan([makeUpdate({ name: 'lodash', currentVersion: '4.17.21', newVersion: '4.17.22' })]) | |
| 423 | const provider = makeCloseProvider([lodashPR()]) | |
| 424 | const buddy = new Buddy(baseConfig()) | |
| 425 | ||
| 426 | await buddy.checkAndCloseSatisfiedPRs(provider, true) | |
| 427 | ||
| 428 | expect(provider.createComment).not.toHaveBeenCalled() | |
| 429 | expect(provider.closePullRequest).not.toHaveBeenCalled() | |
| 430 | }) | |
| 431 | }) | |
| 432 | ||
| 433 | describe('compareVersionsSafe', () => { | |
| 434 | it('distinguishes at-or-beyond, behind, and failed', () => { | |
| 435 | const buddy = new Buddy(baseConfig()) as any | |
| 436 | ||
| 437 | expect(buddy.compareVersionsSafe('4.17.21', '4.17.21')).toBe('at-or-beyond') | |
| 438 | expect(buddy.compareVersionsSafe('4.17.22', '4.17.21')).toBe('at-or-beyond') | |
| 439 | expect(buddy.compareVersionsSafe('4.17.20', '4.17.21')).toBe('behind') | |
| 440 | // GH-Actions-style short versions must fail the comparison rather than | |
| 441 | // report a verdict — a false 'at-or-beyond' would close a live PR. | |
| 442 | expect(buddy.compareVersionsSafe('v4', '4.2.2')).toBe('failed') | |
| 443 | expect(buddy.compareVersionsSafe('garbage', '1.0.0')).toBe('failed') | |
| 444 | }) | |
| 445 | }) | |
| 446 | ||
| 447 | describe('createOrUpdateDashboard', () => { | |
| 448 | let projectDir: string | |
| 449 | ||
| 450 | beforeEach(() => { | |
| 451 | projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'buddy-test-')) | |
| 452 | fs.writeFileSync( | |
| 453 | path.join(projectDir, 'package.json'), | |
| 454 | JSON.stringify({ name: 'dash-fixture', version: '1.0.0' }), | |
| 455 | ) | |
| 456 | }) | |
| 457 | ||
| 458 | afterEach(() => { | |
| 459 | fs.rmSync(projectDir, { recursive: true, force: true }) | |
| 460 | }) | |
| 461 | ||
| 462 | function dashboardConfig(overrides: Record<string, unknown> = {}): BuddyConfig { | |
| 463 | return { | |
| 464 | logLevel: 'silent', | |
| 465 | repository: { provider: 'github', owner: 'test-owner', name: 'test-repo', token: 'test-token' }, | |
| 466 | security: { enabled: false }, | |
| 467 | packages: { detectResolutionDrift: false }, | |
| 468 | dashboard: {}, | |
| 469 | ...overrides, | |
| 470 | } as BuddyConfig | |
| 471 | } | |
| 472 | ||
| 473 | it('success case - creates a fresh issue through the real GitHub provider offline', async () => { | |
| 474 | stubFetch((method, url) => { | |
| 475 | if (method === 'GET' && url.includes('/pulls?')) | |
| 476 | return [] | |
| 477 | if (method === 'GET' && url.includes('/issues?')) | |
| 478 | return [] | |
| 479 | if (method === 'POST' && url.endsWith('/issues')) | |
| 480 | return issueJson(42) | |
| 481 | return undefined | |
| 482 | }) | |
| 483 | ||
| 484 | const buddy = new Buddy(dashboardConfig(), projectDir) | |
| 485 | const issue = await buddy.createOrUpdateDashboard() | |
| 486 | ||
| 487 | expect(issue.number).toBe(42) | |
| 488 | ||
| 489 | const post = fetchCalls.find(call => call.method === 'POST' && call.url.endsWith('/issues')) | |
| 490 | expect(post).toBeDefined() | |
| 491 | expect(post?.url).toContain('/repos/test-owner/test-repo/issues') | |
| 492 | const payload = JSON.parse(post?.body ?? '{}') | |
| 493 | expect(payload.title).toBe('Dependency Dashboard') | |
| 494 | expect(payload.body).toContain(DASHBOARD_MARKER) | |
| 495 | expect(payload.labels).toEqual(['dependencies', 'dashboard']) | |
| 496 | // pin was not configured, so no pin/unpin GraphQL traffic happens. | |
| 497 | expect(fetchCalls.some(call => call.url.includes('/graphql'))).toBe(false) | |
| 498 | }) | |
| 499 | ||
| 500 | it('success case - unpins the dashboard only when config says pin false', async () => { | |
| 501 | stubFetch((method, url) => { | |
| 502 | if (method === 'GET' && url.includes('/pulls?')) | |
| 503 | return [] | |
| 504 | if (method === 'GET' && url.includes('/issues?')) | |
| 505 | return [] | |
| 506 | if (method === 'POST' && url.endsWith('/issues')) | |
| 507 | return issueJson(42) | |
| 508 | if (method === 'GET' && url.includes('/issues/42')) | |
| 509 | return { ...issueJson(42), node_id: 'node-42' } | |
| 510 | if (method === 'POST' && url.endsWith('/graphql')) | |
| 511 | return { data: { unpinIssue: { issue: { number: 42 } } } } | |
| 512 | return undefined | |
| 513 | }) | |
| 514 | ||
| 515 | const buddy = new Buddy(dashboardConfig({ dashboard: { pin: false } }), projectDir) | |
| 516 | await buddy.createOrUpdateDashboard() | |
| 517 | ||
| 518 | const graphql = fetchCalls.find(call => call.url.endsWith('/graphql')) | |
| 519 | expect(graphql).toBeDefined() | |
| 520 | expect(graphql?.body).toContain('unpinIssue') | |
| 521 | }) | |
| 522 | ||
| 523 | it('success case - updates an existing marker-bearing issue instead of creating a duplicate', async () => { | |
| 524 | stubFetch((method, url) => { | |
| 525 | if (method === 'GET' && url.includes('/pulls?')) | |
| 526 | return [] | |
| 527 | if (method === 'GET' && url.includes('/issues?')) | |
| 528 | return [issueJson(7, { title: 'Anything', body: `intro\n\n${DASHBOARD_MARKER}. Read the docs.` })] | |
| 529 | if (method === 'PATCH' && url.includes('/issues/7')) | |
| 530 | return issueJson(7) | |
| 531 | return undefined | |
| 532 | }) | |
| 533 | ||
| 534 | const buddy = new Buddy(dashboardConfig(), projectDir) | |
| 535 | const issue = await buddy.createOrUpdateDashboard() | |
| 536 | ||
| 537 | expect(issue.number).toBe(7) | |
| 538 | expect(fetchCalls.some(call => call.method === 'PATCH' && call.url.includes('/issues/7'))).toBe(true) | |
| 539 | expect(fetchCalls.some(call => call.method === 'POST' && call.url.endsWith('/issues'))).toBe(false) | |
| 540 | }) | |
| 541 | ||
| 542 | it('failure case - rejects non-GitHub providers with a clear error', async () => { | |
| 543 | const buddy = new Buddy( | |
| 544 | { logLevel: 'silent', repository: { provider: 'gitlab', owner: 'o', name: 'r' } } as BuddyConfig, | |
| 545 | projectDir, | |
| 546 | ) | |
| 547 | ||
| 548 | await expect(buddy.createOrUpdateDashboard()).rejects.toThrow('only supported for GitHub') | |
| 549 | }) | |
| 550 | ||
| 551 | it('failure case - rejects a repository missing owner and name', async () => { | |
| 552 | const buddy = new Buddy( | |
| 553 | { logLevel: 'silent', repository: { provider: 'github' } } as unknown as BuddyConfig, | |
| 554 | projectDir, | |
| 555 | ) | |
| 556 | ||
| 557 | await expect(buddy.createOrUpdateDashboard()).rejects.toThrow('owner and name are required') | |
| 558 | }) | |
| 559 | }) | |
| 560 | ||
| 561 | describe('findExistingDashboard', () => { | |
| 562 | function findDashboard(provider: unknown, issueNumber?: number): Promise<Issue | null> { | |
| 563 | const buddy = new Buddy(baseConfig()) | |
| 564 | return (buddy as any).findExistingDashboard(provider, issueNumber) | |
| 565 | } | |
| 566 | ||
| 567 | it('success case - the body marker alone identifies a dashboard', async () => { | |
| 568 | const dashboard = makeIssue({ number: 9, title: 'Renamed by hand', body: `${DASHBOARD_MARKER}.`, labels: [] }) | |
| 569 | const provider = { getIssues: mock(() => Promise.resolve([dashboard])) } | |
| 570 | ||
| 571 | const found = await findDashboard(provider) | |
| 572 | ||
| 573 | expect(found?.number).toBe(9) | |
| 574 | }) | |
| 575 | ||
| 576 | it('success case - labels plus title still match a pre-marker dashboard', async () => { | |
| 577 | const dashboard = makeIssue({ | |
| 578 | number: 10, | |
| 579 | title: 'Dependency Dashboard', | |
| 580 | body: 'older body', | |
| 581 | labels: ['dashboard', 'dependencies'], | |
| 582 | }) | |
| 583 | const provider = { getIssues: mock(() => Promise.resolve([dashboard])) } | |
| 584 | ||
| 585 | const found = await findDashboard(provider) | |
| 586 | ||
| 587 | expect(found?.number).toBe(10) | |
| 588 | }) | |
| 589 | ||
| 590 | it('failure case - never adopts a foreign title-only dashboard', async () => { | |
| 591 | // Renovate's dashboard has the same title; adopting it would overwrite | |
| 592 | // a different tool's issue. | |
| 593 | const foreign = makeIssue({ number: 11, title: 'Dependency Dashboard', body: 'not ours', labels: [] }) | |
| 594 | const provider = { getIssues: mock(() => Promise.resolve([foreign])) } | |
| 595 | ||
| 596 | expect(await findDashboard(provider)).toBeNull() | |
| 597 | }) | |
| 598 | ||
| 599 | it('failure case - a getIssues failure degrades to null instead of throwing', async () => { | |
| 600 | const provider = { getIssues: mock(() => Promise.reject(new Error('api down'))) } | |
| 601 | ||
| 602 | expect(await findDashboard(provider)).toBeNull() | |
| 603 | }) | |
| 604 | ||
| 605 | it('success case - honors an explicit issueNumber even without marker or labels', async () => { | |
| 606 | const plain = makeIssue({ number: 42, title: 'My deps', body: 'plain', labels: [] }) | |
| 607 | const provider = { getIssues: mock(() => Promise.resolve([plain])) } | |
| 608 | ||
| 609 | const found = await findDashboard(provider, 42) | |
| 610 | ||
| 611 | expect(found?.number).toBe(42) | |
| 612 | }) | |
| 613 | ||
| 614 | it('failure case - reports a configured issueNumber that no longer exists as null', async () => { | |
| 615 | const provider = { getIssues: mock(() => Promise.resolve([makeIssue({ number: 1 })])) } | |
| 616 | const buddy = new Buddy(baseConfig()) | |
| 617 | loggerSpy(buddy, 'warn') | |
| 618 | ||
| 619 | expect(await (buddy as any).findExistingDashboard(provider, 42)).toBeNull() | |
| 620 | }) | |
| 621 | }) | |
| 622 | ||
| 623 | describe('createPullRequests guard clauses', () => { | |
| 624 | it('failure case - returns early when no repository is configured', async () => { | |
| 625 | const buddy = new Buddy({ logLevel: 'silent' } as BuddyConfig) | |
| 626 | const errorSpy = loggerSpy(buddy, 'error') | |
| 627 | ||
| 628 | await buddy.createPullRequests(makeScanResult([])) | |
| 629 | ||
| 630 | expect(errorSpy.mock.calls.some((call: unknown[]) => | |
| 631 | String(call[0]).includes('Repository configuration required'))).toBe(true) | |
| 632 | }) | |
| 633 | ||
| 634 | it('failure case - returns early when owner or name is missing', async () => { | |
| 635 | const buddy = new Buddy({ logLevel: 'silent', repository: { provider: 'github' } } as unknown as BuddyConfig) | |
| 636 | const errorSpy = loggerSpy(buddy, 'error') | |
| 637 | ||
| 638 | await buddy.createPullRequests(makeScanResult([])) | |
| 639 | ||
| 640 | expect(errorSpy.mock.calls.some((call: unknown[]) => | |
| 641 | String(call[0]).includes('owner and name are required'))).toBe(true) | |
| 642 | }) | |
| 643 | ||
| 644 | it('failure case - returns early when no token is in the environment', async () => { | |
| 645 | const buddy = new Buddy(baseConfig()) | |
| 646 | const errorSpy = loggerSpy(buddy, 'error') | |
| 647 | ||
| 648 | await buddy.createPullRequests(makeScanResult([])) | |
| 649 | ||
| 650 | expect(errorSpy.mock.calls.some((call: unknown[]) => | |
| 651 | String(call[0]).includes('GITHUB_TOKEN or BUDDY_TOKEN'))).toBe(true) | |
| 652 | }) | |
| 653 | ||
| 654 | it('edge case - skips a workflow-file-only group when BUDDY_TOKEN is absent (#1359)', async () => { | |
| 655 | // GITHUB_TOKEN cannot push workflow files; without the skip, the run | |
| 656 | // would create a branch plus empty commit and fail to open the PR, | |
| 657 | // leaving an orphan branch every run. | |
| 658 | process.env.GITHUB_TOKEN = 'test-token' | |
| 659 | const buddy = new Buddy(baseConfig()) | |
| 660 | const warnSpy = loggerSpy(buddy, 'warn') | |
| 661 | ||
| 662 | const update = makeUpdate({ | |
| 663 | name: 'actions/checkout', | |
| 664 | currentVersion: 'v4', | |
| 665 | newVersion: 'v4.2.2', | |
| 666 | updateType: 'minor', | |
| 667 | dependencyType: 'github-actions', | |
| 668 | file: '.github/workflows/ci.yml', | |
| 669 | }) | |
| 670 | const scanResult: UpdateScanResult = { | |
| 671 | ...makeScanResult([update]), | |
| 672 | groups: [{ | |
| 673 | name: 'GitHub Actions', | |
| 674 | updates: [update], | |
| 675 | updateType: 'minor', | |
| 676 | title: 'chore(deps): update github actions', | |
| 677 | body: 'x', | |
| 678 | }], | |
| 679 | } | |
| 680 | ||
| 681 | await buddy.createPullRequests(scanResult) | |
| 682 | ||
| 683 | expect(warnSpy.mock.calls.some((call: unknown[]) => | |
| 684 | String(call[0]).includes('workflow-file updates require BUDDY_TOKEN'))).toBe(true) | |
| 685 | }) | |
| 686 | }) | |
| 687 | ||
| 688 | describe('run', () => { | |
| 689 | it('edge case - does not create PRs when no updates exist', async () => { | |
| 690 | const buddy = new Buddy(baseConfig({ pullRequest: {} })) | |
| 691 | const scanSpy = spyOn(buddy, 'scanForUpdates').mockResolvedValue(makeScanResult([])) | |
| 692 | const createSpy = spyOn(buddy, 'createPullRequests').mockResolvedValue(undefined) | |
| 693 | restorers.push(() => scanSpy.mockRestore(), () => createSpy.mockRestore()) | |
| 694 | ||
| 695 | const result = await buddy.run() | |
| 696 | ||
| 697 | expect(result.updates).toEqual([]) | |
| 698 | expect(createSpy).not.toHaveBeenCalled() | |
| 699 | }) | |
| 700 | ||
| 701 | it('success case - creates PRs when updates exist and pullRequest is configured', async () => { | |
| 702 | const buddy = new Buddy(baseConfig({ pullRequest: {} })) | |
| 703 | const scanResult = makeScanResult([makeUpdate()]) | |
| 704 | const scanSpy = spyOn(buddy, 'scanForUpdates').mockResolvedValue(scanResult) | |
| 705 | const createSpy = spyOn(buddy, 'createPullRequests').mockResolvedValue(undefined) | |
| 706 | restorers.push(() => scanSpy.mockRestore(), () => createSpy.mockRestore()) | |
| 707 | ||
| 708 | const result = await buddy.run() | |
| 709 | ||
| 710 | expect(result).toBe(scanResult) | |
| 711 | expect(createSpy).toHaveBeenCalledTimes(1) | |
| 712 | expect(createSpy).toHaveBeenCalledWith(scanResult) | |
| 713 | }) | |
| 714 | ||
| 715 | it('edge case - skips PR creation when pullRequest is not configured', async () => { | |
| 716 | const buddy = new Buddy(baseConfig()) | |
| 717 | const scanSpy = spyOn(buddy, 'scanForUpdates').mockResolvedValue(makeScanResult([makeUpdate()])) | |
| 718 | const createSpy = spyOn(buddy, 'createPullRequests').mockResolvedValue(undefined) | |
| 719 | restorers.push(() => scanSpy.mockRestore(), () => createSpy.mockRestore()) | |
| 720 | ||
| 721 | await buddy.run() | |
| 722 | ||
| 723 | expect(createSpy).not.toHaveBeenCalled() | |
| 724 | }) | |
| 725 | }) | |
| 726 | ||
| 727 | describe('groupUpdatesByConfig', () => { | |
| 728 | it('success case - claims by glob, filters by per-group strategy, and falls through to defaults', () => { | |
| 729 | const config = baseConfig({ | |
| 730 | packages: { | |
| 731 | groups: [ | |
| 732 | // Multi-star pattern first, so it claims before the broad one — | |
| 733 | // and pins the fix for the first-star-only regex bug. | |
| 734 | { name: 'FooTypes', patterns: ['@types/*-foo'] }, | |
| 735 | { name: 'Types', patterns: ['@types/*'], strategy: 'patch' }, | |
| 736 | ], | |
| 737 | }, | |
| 738 | }) | |
| 739 | const buddy = new Buddy(config) as any | |
| 740 | const updates = [ | |
| 741 | makeUpdate({ name: '@types/node', updateType: 'minor', newVersion: '18.1.0' }), | |
| 742 | makeUpdate({ name: '@types/bun-foo', updateType: 'patch' }), | |
| 743 | makeUpdate({ name: 'react', updateType: 'patch' }), | |
| 744 | ] | |
| 745 | ||
| 746 | const groups = buddy.groupUpdatesByConfig(updates) | |
| 747 | ||
| 748 | const fooTypes = groups.find((group: { name: string }) => group.name === 'FooTypes') | |
| 749 | expect(fooTypes?.updates.map((update: PackageUpdate) => update.name)).toEqual(['@types/bun-foo']) | |
| 750 | ||
| 751 | // '@types/node' is claimed by the Types group but its minor update is | |
| 752 | // filtered out by the group's patch strategy. | |
| 753 | const types = groups.find((group: { name: string }) => group.name === 'Types') | |
| 754 | expect(types?.updates).toEqual([]) | |
| 755 | ||
| 756 | const nonMajor = groups.find((group: { name: string }) => group.name === 'Non-Major Updates') | |
| 757 | expect(nonMajor?.updates.map((update: PackageUpdate) => update.name)).toEqual(['react']) | |
| 758 | }) | |
| 759 | ||
| 760 | it('edge case - a malformed glob pattern claims nothing and does not throw', () => { | |
| 761 | const config = baseConfig({ | |
| 762 | packages: { groups: [{ name: 'Bad', patterns: ['['] }] }, | |
| 763 | }) | |
| 764 | const buddy = new Buddy(config) as any | |
| 765 | const updates = [makeUpdate({ name: 'react', updateType: 'patch' })] | |
| 766 | ||
| 767 | const groups = buddy.groupUpdatesByConfig(updates) | |
| 768 | ||
| 769 | expect(groups.find((group: { name: string }) => group.name === 'Bad')).toBeUndefined() | |
| 770 | const nonMajor = groups.find((group: { name: string }) => group.name === 'Non-Major Updates') | |
| 771 | expect(nonMajor?.updates.map((update: PackageUpdate) => update.name)).toEqual(['react']) | |
| 772 | }) | |
| 773 | ||
| 774 | it('success case - rule-named updates form one group before legacy groups run', () => { | |
| 775 | const config = baseConfig({ | |
| 776 | packages: { | |
| 777 | rules: [{ matchPackages: ['react*'], groupName: 'React' }], | |
| 778 | }, | |
| 779 | }) | |
| 780 | const buddy = new Buddy(config) as any | |
| 781 | const updates = [ | |
| 782 | makeUpdate({ name: 'react', updateType: 'minor', newVersion: '18.3.0' }), | |
| 783 | makeUpdate({ name: 'react-dom', updateType: 'patch' }), | |
| 784 | makeUpdate({ name: 'lodash', updateType: 'patch' }), | |
| 785 | ] | |
| 786 | ||
| 787 | const groups = buddy.groupUpdatesByConfig(updates) | |
| 788 | ||
| 789 | const reactGroup = groups.find((group: { name: string }) => group.name === 'React') | |
| 790 | expect(reactGroup?.updates.map((update: PackageUpdate) => update.name).sort()).toEqual(['react', 'react-dom']) | |
| 791 | expect(reactGroup?.updateType).toBe('minor') | |
| 792 | ||
| 793 | const nonMajor = groups.find((group: { name: string }) => group.name === 'Non-Major Updates') | |
| 794 | expect(nonMajor?.updates.map((update: PackageUpdate) => update.name)).toEqual(['lodash']) | |
| 795 | }) | |
| 796 | }) | |
| 797 | ||
| 798 | describe('isSimilarPRTitle', () => { | |
| 799 | function similar(existingTitle: string, newTitle: string): boolean { | |
| 800 | const buddy = new Buddy(baseConfig()) as any | |
| 801 | return buddy.isSimilarPRTitle(existingTitle, newTitle) | |
| 802 | } | |
| 803 | ||
| 804 | it('separates ecosystems and major vs non-major groups', () => { | |
| 805 | // Two GitHub Actions titles land in the same PR. | |
| 806 | expect(similar('chore(deps): update github actions', 'chore(deps): update GitHub Actions dependencies')).toBe(true) | |
| 807 | // A GH-Actions PR is not the npm grouped PR. | |
| 808 | expect(similar('chore(deps): update github actions', 'chore(deps): update all non-major dependencies')).toBe(false) | |
| 809 | // Docker images share one PR. | |
| 810 | expect(similar('chore(deps): update docker image node', 'chore(deps): update docker image nginx')).toBe(true) | |
| 811 | // Major and non-major grouped PRs stay separate. | |
| 812 | expect(similar('chore(deps): update all non-major dependencies', 'chore(deps): update dependencies (major)')).toBe(false) | |
| 813 | // Same single dependency, new version: update the existing PR. | |
| 814 | expect(similar('chore(deps): update dependency react to v18', 'chore(deps): update dependency react to v19')).toBe(true) | |
| 815 | // Different dependencies are different PRs. | |
| 816 | expect(similar('chore(deps): update dependency react to v18', 'chore(deps): update dependency vue to v3')).toBe(false) | |
| 817 | }) | |
| 818 | }) | |
| 819 | ||
| 820 | describe('dropOverriddenUpdates', () => { | |
| 821 | function packageJsonFile(content: string): PackageFile { | |
| 822 | return { path: 'package.json', type: 'package.json', content, dependencies: [] } | |
| 823 | } | |
| 824 | ||
| 825 | it('success case - drops a pinned package but keeps one whose override admits the new version', async () => { | |
| 826 | const buddy = new Buddy(baseConfig()) as any | |
| 827 | const updates = [ | |
| 828 | makeUpdate({ name: 'lodash', currentVersion: '4.17.20', newVersion: '4.17.21' }), | |
| 829 | makeUpdate({ name: 'axios', currentVersion: '1.6.0', newVersion: '1.7.0' }), | |
| 830 | ] | |
| 831 | const files = [packageJsonFile(JSON.stringify({ | |
| 832 | overrides: { lodash: '4.17.20' }, | |
| 833 | resolutions: { axios: '^1.0.0' }, | |
| 834 | }))] | |
| 835 | ||
| 836 | const result = await buddy.dropOverriddenUpdates(updates, files) | |
| 837 | ||
| 838 | // lodash's exact override excludes the target; axios's range admits it. | |
| 839 | expect(result.map((update: PackageUpdate) => update.name)).toEqual(['axios']) | |
| 840 | }) | |
| 841 | ||
| 842 | it('edge case - a manifest with unparseable JSON contributes nothing and does not throw', async () => { | |
| 843 | const buddy = new Buddy(baseConfig()) as any | |
| 844 | const updates = [makeUpdate({ name: 'lodash' })] | |
| 845 | ||
| 846 | const result = await buddy.dropOverriddenUpdates(updates, [packageJsonFile('{ not json')]) | |
| 847 | ||
| 848 | expect(result).toEqual(updates) | |
| 849 | }) | |
| 850 | }) | |
| 851 | ||
| 852 | describe('checkDockerfilesForUpdates', () => { | |
| 853 | function dockerFile(version: string): PackageFile { | |
| 854 | return { | |
| 855 | path: 'Dockerfile', | |
| 856 | type: 'Dockerfile', | |
| 857 | content: `FROM node:${version}`, | |
| 858 | dependencies: [{ name: 'node', currentVersion: version, type: 'docker-image', file: 'Dockerfile' }], | |
| 859 | } | |
| 860 | } | |
| 861 | ||
| 862 | it('edge case - respects wildcard tags without touching the registry', async () => { | |
| 863 | // 'latest' short-circuits before any fetch; the preload guard proves no | |
| 864 | // network call happened by failing the test loudly if one did. | |
| 865 | const buddy = new Buddy(baseConfig()) as any | |
| 866 | ||
| 867 | const result = await buddy.checkDockerfilesForUpdates([dockerFile('latest')]) | |
| 868 | ||
| 869 | expect(result).toEqual([]) | |
| 870 | }) | |
| 871 | ||
| 872 | it('failure case - tolerates registry failures per-image instead of failing the scan', async () => { | |
| 873 | stubFetch(() => undefined) // every registry request comes back 400 | |
| 874 | const buddy = new Buddy(baseConfig()) as any | |
| 875 | ||
| 876 | const result = await buddy.checkDockerfilesForUpdates([dockerFile('20.11.1')]) | |
| 877 | ||
| 878 | expect(result).toEqual([]) | |
| 879 | }) | |
| 880 | }) | |
| 881 | ||
| 882 | describe('analyzeMajors', () => { | |
| 883 | it('edge case - returns an empty report when majorUpgrades is not enabled', async () => { | |
| 884 | // The contract that keeps PRs byte-identical when the feature is off: | |
| 885 | // no report, no draft, and no AI client constructed (the preload fetch | |
| 886 | // guard would catch any network attempt). | |
| 887 | const buddy = new Buddy({ logLevel: 'silent' } as BuddyConfig) as any | |
| 888 | ||
| 889 | const result = await buddy.analyzeMajors( | |
| 890 | [makeUpdate({ updateType: 'major', newVersion: '5.0.0' })], | |
| 891 | ['package.json'], | |
| 892 | ) | |
| 893 | ||
| 894 | expect(result).toEqual({ report: '', draft: false }) | |
| 895 | }) | |
| 896 | }) | |
| 897 | }) | |
test/github-provider-api.test.tsadded+736-0
| @@ -0,0 +1,736 @@ | ||
| 1 | import { Buffer } from 'node:buffer' | |
| 2 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 3 | import { GitHubProvider } from '../src/git/github-provider' | |
| 4 | import { GitHubApiError } from '../src/utils/errors' | |
| 5 | import { FileChangeValidationError } from '../src/utils/file-changes' | |
| 6 | import { Logger } from '../src/utils/logger' | |
| 7 | ||
| 8 | const realFetch = globalThis.fetch | |
| 9 | ||
| 10 | afterEach(() => { | |
| 11 | globalThis.fetch = realFetch | |
| 12 | }) | |
| 13 | ||
| 14 | interface RecordedCall { | |
| 15 | url: string | |
| 16 | method: string | |
| 17 | headers: Record<string, string> | |
| 18 | body: string | undefined | |
| 19 | } | |
| 20 | ||
| 21 | /** | |
| 22 | * Replace fetch with a router keyed off the recorded call, recording every | |
| 23 | * request so tests can assert on URLs, methods, headers and bodies. | |
| 24 | */ | |
| 25 | function stubFetch(route: (call: RecordedCall) => Response | Promise<Response>): RecordedCall[] { | |
| 26 | const calls: RecordedCall[] = [] | |
| 27 | globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { | |
| 28 | const call: RecordedCall = { | |
| 29 | url: String(input), | |
| 30 | method: init?.method ?? 'GET', | |
| 31 | headers: (init?.headers ?? {}) as Record<string, string>, | |
| 32 | body: typeof init?.body === 'string' ? init.body : undefined, | |
| 33 | } | |
| 34 | calls.push(call) | |
| 35 | return route(call) | |
| 36 | }) as unknown as typeof fetch | |
| 37 | return calls | |
| 38 | } | |
| 39 | ||
| 40 | function json(body: unknown, status = 200): Response { | |
| 41 | return new Response(JSON.stringify(body), { | |
| 42 | status, | |
| 43 | headers: { 'content-type': 'application/json' }, | |
| 44 | }) | |
| 45 | } | |
| 46 | ||
| 47 | function github(token = 'token'): GitHubProvider { | |
| 48 | return new GitHubProvider(token, 'owner', 'repo', false, undefined, 'https://api.github.test', Logger.silent()) | |
| 49 | } | |
| 50 | ||
| 51 | function prResponse(number: number, overrides: Record<string, unknown> = {}): Record<string, unknown> { | |
| 52 | return { | |
| 53 | number, | |
| 54 | title: `chore(deps): update #${number}`, | |
| 55 | body: 'pr body', | |
| 56 | head: { ref: 'buddy/update-x' }, | |
| 57 | base: { ref: 'main' }, | |
| 58 | state: 'open', | |
| 59 | html_url: `https://github.com/owner/repo/pull/${number}`, | |
| 60 | created_at: '2026-01-02T03:04:05Z', | |
| 61 | updated_at: '2026-01-02T03:04:06Z', | |
| 62 | merged_at: null, | |
| 63 | draft: false, | |
| 64 | user: { login: 'github-actions[bot]' }, | |
| 65 | ...overrides, | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | function issueResponse(number: number, overrides: Record<string, unknown> = {}): Record<string, unknown> { | |
| 70 | return { | |
| 71 | number, | |
| 72 | title: `issue #${number}`, | |
| 73 | body: 'issue body', | |
| 74 | state: 'open', | |
| 75 | html_url: `https://github.com/owner/repo/issues/${number}`, | |
| 76 | created_at: '2026-01-02T03:04:05Z', | |
| 77 | updated_at: '2026-01-02T03:04:06Z', | |
| 78 | closed_at: null, | |
| 79 | user: { login: 'octocat' }, | |
| 80 | assignees: [], | |
| 81 | labels: [], | |
| 82 | ...overrides, | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | describe('gitHubProvider API surface', () => { | |
| 87 | describe('runCommand', () => { | |
| 88 | it('success case - resolves accumulated stdout on exit 0', async () => { | |
| 89 | const output = await github().runCommand('sh', ['-c', 'echo hello']) | |
| 90 | expect(output).toContain('hello') | |
| 91 | }) | |
| 92 | ||
| 93 | it('failure case - a failed command redacts the token and token-shaped strings from stderr', async () => { | |
| 94 | const prov = github('supersecrettoken1234') | |
| 95 | const error = await prov | |
| 96 | .runCommand('sh', ['-c', '>&2 echo "auth supersecrettoken1234 ghp_ABCDEFGHIJKLMNOPQRST12345"; exit 3']) | |
| 97 | .catch((e: unknown) => e) | |
| 98 | ||
| 99 | expect(error).toBeInstanceOf(Error) | |
| 100 | const message = (error as Error).message | |
| 101 | expect(message).toContain('code 3') | |
| 102 | expect(message).toContain('[REDACTED]') | |
| 103 | expect(message).not.toContain('supersecrettoken1234') | |
| 104 | expect(message).not.toContain('ghp_ABCDEFGHIJKLMNOPQRST12345') | |
| 105 | }) | |
| 106 | }) | |
| 107 | ||
| 108 | describe('createPullRequest', () => { | |
| 109 | const options = { title: 't', body: 'b', head: 'buddy/x', base: 'main' } | |
| 110 | ||
| 111 | it('failure case - falls back to the API when the gh CLI is missing', async () => { | |
| 112 | const prov = github() | |
| 113 | ;(prov as any).runCommand = async () => { | |
| 114 | throw new Error('gh: not found') | |
| 115 | } | |
| 116 | const calls = stubFetch(() => json(prResponse(55), 201)) | |
| 117 | ||
| 118 | const pr = await prov.createPullRequest(options) | |
| 119 | ||
| 120 | expect(pr.number).toBe(55) | |
| 121 | expect(pr.head).toBe('buddy/update-x') | |
| 122 | expect(pr.url).toBe('https://github.com/owner/repo/pull/55') | |
| 123 | expect(pr.createdAt).toEqual(new Date('2026-01-02T03:04:05Z')) | |
| 124 | expect(calls.filter(c => c.method === 'POST' && c.url.endsWith('/pulls'))).toHaveLength(1) | |
| 125 | }) | |
| 126 | ||
| 127 | it('failure case - falls back to the API when gh prints no PR URL', async () => { | |
| 128 | const prov = github() | |
| 129 | ;(prov as any).runCommand = async () => 'no url in this output\n' | |
| 130 | const calls = stubFetch(() => json(prResponse(56), 201)) | |
| 131 | ||
| 132 | const pr = await prov.createPullRequest(options) | |
| 133 | ||
| 134 | expect(pr.number).toBe(56) | |
| 135 | expect(calls.filter(c => c.method === 'POST' && c.url.endsWith('/pulls'))).toHaveLength(1) | |
| 136 | }) | |
| 137 | ||
| 138 | it('success case - parses the PR number from gh output and adds labels via API afterwards', async () => { | |
| 139 | const prov = github() | |
| 140 | const commands: Array<{ command: string, args: string[] }> = [] | |
| 141 | ;(prov as any).runCommand = async (command: string, args: string[]) => { | |
| 142 | commands.push({ command, args }) | |
| 143 | return 'https://github.com/owner/repo/pull/123\n' | |
| 144 | } | |
| 145 | const apiCalls: Array<{ endpoint: string, data: unknown }> = [] | |
| 146 | ;(prov as any).apiRequest = async (endpoint: string, data?: unknown) => { | |
| 147 | apiCalls.push({ endpoint, data }) | |
| 148 | return {} | |
| 149 | } | |
| 150 | ||
| 151 | const pr = await prov.createPullRequest({ | |
| 152 | ...options, | |
| 153 | labels: ['dependencies'], | |
| 154 | reviewers: ['alice'], | |
| 155 | }) | |
| 156 | ||
| 157 | expect(pr.number).toBe(123) | |
| 158 | expect(pr.url).toBe('https://github.com/owner/repo/pull/123') | |
| 159 | expect(commands[0].command).toBe('gh') | |
| 160 | expect(commands[0].args).toContain('--reviewer') | |
| 161 | const labelCall = apiCalls.find(c => c.endpoint === 'POST /repos/owner/repo/issues/123/labels') | |
| 162 | expect(labelCall?.data).toEqual({ labels: ['dependencies'] }) | |
| 163 | }) | |
| 164 | ||
| 165 | it('failure case - the API path survives a reviewer-add failure and retries labels one-by-one', async () => { | |
| 166 | const prov = github() | |
| 167 | ;(prov as any).runCommand = async () => { | |
| 168 | throw new Error('gh missing') | |
| 169 | } | |
| 170 | const labelBodies: string[] = [] | |
| 171 | stubFetch((call) => { | |
| 172 | if (call.url.includes('/requested_reviewers')) | |
| 173 | return json({ message: 'Validation Failed' }, 422) | |
| 174 | if (call.url.includes('/labels')) { | |
| 175 | labelBodies.push(call.body ?? '') | |
| 176 | const labels = JSON.parse(call.body ?? '{}').labels ?? [] | |
| 177 | return labels.length > 1 ? json({ message: 'Validation Failed' }, 422) : json({}) | |
| 178 | } | |
| 179 | return json(prResponse(9), 201) | |
| 180 | }) | |
| 181 | ||
| 182 | const pr = await prov.createPullRequest({ | |
| 183 | ...options, | |
| 184 | reviewers: ['alice'], | |
| 185 | labels: ['a', 'b'], | |
| 186 | }) | |
| 187 | ||
| 188 | expect(pr.number).toBe(9) | |
| 189 | expect(labelBodies.map(body => JSON.parse(body).labels)).toEqual([['a', 'b'], ['a'], ['b']]) | |
| 190 | }) | |
| 191 | }) | |
| 192 | ||
| 193 | describe('updatePullRequest', () => { | |
| 194 | it('success case - PATCHes only the provided fields and falls back per label', async () => { | |
| 195 | const calls = stubFetch((call) => { | |
| 196 | if (call.method === 'PATCH') | |
| 197 | return json(prResponse(7)) | |
| 198 | if (call.method === 'PUT') | |
| 199 | return json({ message: 'Forbidden' }, 403) | |
| 200 | return json({}) | |
| 201 | }) | |
| 202 | ||
| 203 | const pr = await github().updatePullRequest(7, { title: 'x', labels: ['a'] }) | |
| 204 | ||
| 205 | const patch = calls.find(c => c.method === 'PATCH') | |
| 206 | expect(JSON.parse(patch?.body ?? '')).toEqual({ title: 'x' }) | |
| 207 | const put = calls.find(c => c.method === 'PUT') | |
| 208 | expect(put?.url).toContain('/issues/7/labels') | |
| 209 | const post = calls.find(c => c.method === 'POST') | |
| 210 | expect(JSON.parse(post?.body ?? '')).toEqual({ labels: ['a'] }) | |
| 211 | expect(pr.number).toBe(7) | |
| 212 | expect(pr.title).toBe('chore(deps): update #7') | |
| 213 | expect(pr.labels).toEqual(['a']) | |
| 214 | }) | |
| 215 | }) | |
| 216 | ||
| 217 | describe('closePullRequest', () => { | |
| 218 | it('edge case - tolerates a non-JSON success response', async () => { | |
| 219 | const calls = stubFetch(() => | |
| 220 | new Response('', { status: 200, headers: { 'content-type': 'text/plain' } })) | |
| 221 | ||
| 222 | await github().closePullRequest(7) | |
| 223 | ||
| 224 | expect(calls[0].method).toBe('PATCH') | |
| 225 | expect(JSON.parse(calls[0].body ?? '')).toEqual({ state: 'closed' }) | |
| 226 | }) | |
| 227 | }) | |
| 228 | ||
| 229 | describe('mergePullRequest', () => { | |
| 230 | it('success case - maps the strategy, deletes the head branch, and swallows cleanup failure', async () => { | |
| 231 | const prov = github() | |
| 232 | const apiCalls: Array<{ endpoint: string, data: unknown }> = [] | |
| 233 | ;(prov as any).apiRequest = async (endpoint: string, data?: unknown) => { | |
| 234 | apiCalls.push({ endpoint, data }) | |
| 235 | if (endpoint.startsWith('GET')) | |
| 236 | return { head: { ref: 'buddy/x' } } | |
| 237 | return {} | |
| 238 | } | |
| 239 | const deleted: string[] = [] | |
| 240 | ;(prov as any).deleteBranch = async (name: string) => { | |
| 241 | deleted.push(name) | |
| 242 | throw new Error('branch is protected') | |
| 243 | } | |
| 244 | ||
| 245 | await prov.mergePullRequest(9, 'squash') | |
| 246 | ||
| 247 | const merge = apiCalls.find(c => c.endpoint === 'PUT /repos/owner/repo/pulls/9/merge') | |
| 248 | expect(merge?.data).toEqual({ merge_method: 'squash' }) | |
| 249 | expect(deleted).toEqual(['buddy/x']) | |
| 250 | }) | |
| 251 | }) | |
| 252 | ||
| 253 | describe('hasWriteAccess', () => { | |
| 254 | it('success case - grants admin access', async () => { | |
| 255 | stubFetch(() => json({ permission: 'admin' })) | |
| 256 | expect(await github().hasWriteAccess('alice')).toBe(true) | |
| 257 | }) | |
| 258 | ||
| 259 | it('success case - denies read-only collaborators', async () => { | |
| 260 | stubFetch(() => json({ permission: 'read' })) | |
| 261 | expect(await github().hasWriteAccess('alice')).toBe(false) | |
| 262 | }) | |
| 263 | ||
| 264 | it('failure case - a 404 (not a collaborator) is a definitive no', async () => { | |
| 265 | stubFetch(() => json({ message: 'Not Found' }, 404)) | |
| 266 | expect(await github().hasWriteAccess('stranger')).toBe(false) | |
| 267 | }) | |
| 268 | ||
| 269 | it('failure case - an unknown error must not grant access', async () => { | |
| 270 | stubFetch(() => json({ message: 'boom' }, 500)) | |
| 271 | expect(await github().hasWriteAccess('alice')).toBe(false) | |
| 272 | }) | |
| 273 | ||
| 274 | it('edge case - the username is URL-encoded', async () => { | |
| 275 | const calls = stubFetch(() => json({ permission: 'admin' })) | |
| 276 | await github().hasWriteAccess('a b') | |
| 277 | expect(calls[0].url).toContain('/collaborators/a%20b/permission') | |
| 278 | }) | |
| 279 | }) | |
| 280 | ||
| 281 | describe('getWorkflowRunLogs', () => { | |
| 282 | it('success case - collapses non-printable bytes to spaces but keeps tabs and newlines', async () => { | |
| 283 | stubFetch(() => new Response(new Uint8Array([...Buffer.from('ok\x00\x01text\tend\n')]))) | |
| 284 | ||
| 285 | const logs = await github().getWorkflowRunLogs(5) | |
| 286 | ||
| 287 | expect(logs).toBe('ok text\tend\n') | |
| 288 | }) | |
| 289 | ||
| 290 | it('failure case - unavailable logs read as null', async () => { | |
| 291 | stubFetch(() => json({ message: 'Not Found' }, 404)) | |
| 292 | expect(await github().getWorkflowRunLogs(5)).toBeNull() | |
| 293 | }) | |
| 294 | ||
| 295 | it('failure case - an unreadable body reads as null rather than throwing', async () => { | |
| 296 | stubFetch(() => ({ | |
| 297 | ok: true, | |
| 298 | status: 200, | |
| 299 | headers: new Headers(), | |
| 300 | arrayBuffer: () => Promise.reject(new Error('corrupt archive')), | |
| 301 | }) as unknown as Response) | |
| 302 | ||
| 303 | expect(await github().getWorkflowRunLogs(5)).toBeNull() | |
| 304 | }) | |
| 305 | }) | |
| 306 | ||
| 307 | describe('createCheckRun', () => { | |
| 308 | it('failure case - logs instead of throwing when checks:write is missing', async () => { | |
| 309 | stubFetch(() => json({ message: 'Resource not accessible by integration' }, 403)) | |
| 310 | ||
| 311 | await expect( | |
| 312 | github().createCheckRun('buddy gate', 'abc', { conclusion: 'failure', title: 't', summary: 's' }), | |
| 313 | ).resolves.toBeUndefined() | |
| 314 | }) | |
| 315 | ||
| 316 | it('success case - posts a completed check run with the result output', async () => { | |
| 317 | const calls = stubFetch(() => json({}, 201)) | |
| 318 | ||
| 319 | await github().createCheckRun('buddy gate', 'abc123', { | |
| 320 | conclusion: 'success', | |
| 321 | title: 'All gates passed', | |
| 322 | summary: 'details', | |
| 323 | }) | |
| 324 | ||
| 325 | expect(calls[0].url).toBe('https://api.github.test/repos/owner/repo/check-runs') | |
| 326 | const body = JSON.parse(calls[0].body ?? '') | |
| 327 | expect(body.name).toBe('buddy gate') | |
| 328 | expect(body.head_sha).toBe('abc123') | |
| 329 | expect(body.status).toBe('completed') | |
| 330 | expect(body.conclusion).toBe('success') | |
| 331 | expect(body.output).toEqual({ title: 'All gates passed', summary: 'details' }) | |
| 332 | }) | |
| 333 | }) | |
| 334 | ||
| 335 | describe('getFileContent', () => { | |
| 336 | it('success case - decodes base64 content', async () => { | |
| 337 | const calls = stubFetch(() => json({ | |
| 338 | content: Buffer.from('hello').toString('base64'), | |
| 339 | encoding: 'base64', | |
| 340 | })) | |
| 341 | ||
| 342 | const content = await github().getFileContent('docs/read me.md', 'feat/x') | |
| 343 | ||
| 344 | expect(content).toBe('hello') | |
| 345 | expect(calls[0].url).toContain('/contents/docs%2Fread%20me.md?ref=feat%2Fx') | |
| 346 | }) | |
| 347 | ||
| 348 | it('edge case - a response with no content reads as null', async () => { | |
| 349 | stubFetch(() => json({})) | |
| 350 | expect(await github().getFileContent('a.txt', 'main')).toBeNull() | |
| 351 | }) | |
| 352 | ||
| 353 | it('failure case - a 404 reads as null', async () => { | |
| 354 | stubFetch(() => json({ message: 'Not Found' }, 404)) | |
| 355 | expect(await github().getFileContent('missing.txt', 'main')).toBeNull() | |
| 356 | }) | |
| 357 | ||
| 358 | it('failure case - other failures rethrow as typed errors', async () => { | |
| 359 | stubFetch(() => json({ message: 'boom' }, 500)) | |
| 360 | const error = await github().getFileContent('a.txt', 'main').catch((e: unknown) => e) | |
| 361 | expect(error).toBeInstanceOf(GitHubApiError) | |
| 362 | expect((error as GitHubApiError).status).toBe(500) | |
| 363 | }) | |
| 364 | }) | |
| 365 | ||
| 366 | describe('getPullRequestDiff', () => { | |
| 367 | it('success case - asks for the diff media type and returns the text verbatim', async () => { | |
| 368 | const calls = stubFetch(() => new Response('diff --git a/x b/x')) | |
| 369 | ||
| 370 | const diff = await github().getPullRequestDiff(3) | |
| 371 | ||
| 372 | expect(diff).toBe('diff --git a/x b/x') | |
| 373 | expect(calls[0].headers.Accept).toBe('application/vnd.github.v3.diff') | |
| 374 | }) | |
| 375 | ||
| 376 | it('failure case - throws a typed error on failure', async () => { | |
| 377 | stubFetch(() => json({ message: 'Not Found' }, 404)) | |
| 378 | const error = await github().getPullRequestDiff(3).catch((e: unknown) => e) | |
| 379 | expect(error).toBeInstanceOf(GitHubApiError) | |
| 380 | expect((error as GitHubApiError).status).toBe(404) | |
| 381 | }) | |
| 382 | }) | |
| 383 | ||
| 384 | describe('createReview', () => { | |
| 385 | it('failure case - reposts the summary alone when inline anchors are rejected', async () => { | |
| 386 | const calls = stubFetch((call) => { | |
| 387 | const body = JSON.parse(call.body ?? '{}') | |
| 388 | return Array.isArray(body.comments) | |
| 389 | ? json({ message: 'Validation Failed' }, 422) | |
| 390 | : json({}) | |
| 391 | }) | |
| 392 | ||
| 393 | const result = await github().createReview(4, { | |
| 394 | body: 'summary', | |
| 395 | event: 'COMMENT', | |
| 396 | comments: [{ path: 'a.ts', line: 1, side: 'RIGHT', body: 'finding' }], | |
| 397 | }) | |
| 398 | ||
| 399 | expect(result).toEqual({ posted: true, inlineComments: 0 }) | |
| 400 | expect(calls).toHaveLength(2) | |
| 401 | }) | |
| 402 | ||
| 403 | it('failure case - a rejected review with no comments is not silently retried', async () => { | |
| 404 | const calls = stubFetch(() => json({ message: 'Validation Failed' }, 422)) | |
| 405 | const error = await github() | |
| 406 | .createReview(4, { body: 'summary', event: 'COMMENT' }) | |
| 407 | .catch((e: unknown) => e) | |
| 408 | ||
| 409 | expect(error).toBeInstanceOf(GitHubApiError) | |
| 410 | expect(calls).toHaveLength(1) | |
| 411 | }) | |
| 412 | }) | |
| 413 | ||
| 414 | describe('enableAutoMerge', () => { | |
| 415 | it('failure case - treats GraphQL errors as a fallback signal', async () => { | |
| 416 | stubFetch(call => call.url.includes('/graphql') | |
| 417 | ? json({ errors: [{ message: 'Pull request is in clean status' }] }) | |
| 418 | : json({ node_id: 'PR_abc' })) | |
| 419 | ||
| 420 | expect(await github().enableAutoMerge(5)).toBe(false) | |
| 421 | }) | |
| 422 | ||
| 423 | it('success case - uppercases the merge method in the mutation variables', async () => { | |
| 424 | const calls = stubFetch(call => call.url.includes('/graphql') | |
| 425 | ? json({ data: {} }) | |
| 426 | : json({ node_id: 'PR_abc' })) | |
| 427 | ||
| 428 | expect(await github().enableAutoMerge(5, 'squash')).toBe(true) | |
| 429 | ||
| 430 | const gql = calls.find(c => c.url.includes('/graphql')) | |
| 431 | expect(JSON.parse(gql?.body ?? '').variables).toEqual({ | |
| 432 | pullRequestId: 'PR_abc', | |
| 433 | mergeMethod: 'SQUASH', | |
| 434 | }) | |
| 435 | }) | |
| 436 | }) | |
| 437 | ||
| 438 | describe('listReviewThreads', () => { | |
| 439 | it('success case - maps thread nodes and drops the unusable ones', async () => { | |
| 440 | stubFetch(() => json({ | |
| 441 | data: { | |
| 442 | repository: { | |
| 443 | pullRequest: { | |
| 444 | reviewThreads: { | |
| 445 | nodes: [ | |
| 446 | { | |
| 447 | id: 'T1', | |
| 448 | isResolved: true, | |
| 449 | path: 'src/a.ts', | |
| 450 | comments: { nodes: [{ author: { login: 'alice' } }, { author: null }] }, | |
| 451 | }, | |
| 452 | null, | |
| 453 | { id: 'T2', isResolved: false, comments: { nodes: [] } }, | |
| 454 | ], | |
| 455 | }, | |
| 456 | }, | |
| 457 | }, | |
| 458 | }, | |
| 459 | })) | |
| 460 | ||
| 461 | const threads = await github().listReviewThreads(8) | |
| 462 | ||
| 463 | expect(threads).toHaveLength(2) | |
| 464 | expect(threads[0]).toEqual({ id: 'T1', isResolved: true, path: 'src/a.ts', authorLogins: ['alice'] }) | |
| 465 | expect(threads[1]).toEqual({ id: 'T2', isResolved: false, authorLogins: [] }) | |
| 466 | expect('path' in threads[1]).toBe(false) | |
| 467 | }) | |
| 468 | ||
| 469 | it('failure case - GraphQL errors read as no threads', async () => { | |
| 470 | stubFetch(() => json({ errors: [{ message: 'boom' }] })) | |
| 471 | expect(await github().listReviewThreads(8)).toEqual([]) | |
| 472 | }) | |
| 473 | ||
| 474 | it('failure case - an unreadable conversation reads as nothing to tidy', async () => { | |
| 475 | stubFetch(() => json({ message: 'boom' }, 500)) | |
| 476 | expect(await github().listReviewThreads(8)).toEqual([]) | |
| 477 | }) | |
| 478 | }) | |
| 479 | ||
| 480 | describe('getPullRequestChecksState', () => { | |
| 481 | function stubChecks(checkRuns: unknown, status: unknown): void { | |
| 482 | stubFetch((call) => { | |
| 483 | if (call.url.includes('/check-runs')) | |
| 484 | return json(checkRuns) | |
| 485 | if (call.url.includes('/status')) | |
| 486 | return json(status) | |
| 487 | return json({ head: { sha: 'abc' } }) | |
| 488 | }) | |
| 489 | } | |
| 490 | ||
| 491 | it('edge case - no runs and no statuses read as none', async () => { | |
| 492 | stubChecks({ check_runs: [] }, { statuses: [], state: 'pending' }) | |
| 493 | expect(await github().getPullRequestChecksState(9)).toBe('none') | |
| 494 | }) | |
| 495 | ||
| 496 | it('failure case - a timed-out run reads as failure', async () => { | |
| 497 | stubChecks( | |
| 498 | { check_runs: [{ status: 'completed', conclusion: 'timed_out' }] }, | |
| 499 | { statuses: [], state: 'success' }, | |
| 500 | ) | |
| 501 | expect(await github().getPullRequestChecksState(9)).toBe('failure') | |
| 502 | }) | |
| 503 | ||
| 504 | it('edge case - green runs with a pending legacy status read as pending', async () => { | |
| 505 | stubChecks( | |
| 506 | { check_runs: [{ status: 'completed', conclusion: 'success' }] }, | |
| 507 | { statuses: [{ state: 'pending' }], state: 'pending' }, | |
| 508 | ) | |
| 509 | expect(await github().getPullRequestChecksState(9)).toBe('pending') | |
| 510 | }) | |
| 511 | ||
| 512 | it('success case - everything green reads as success', async () => { | |
| 513 | stubChecks( | |
| 514 | { check_runs: [{ status: 'completed', conclusion: 'success' }] }, | |
| 515 | { statuses: [{ state: 'success' }], state: 'success' }, | |
| 516 | ) | |
| 517 | expect(await github().getPullRequestChecksState(9)).toBe('success') | |
| 518 | }) | |
| 519 | ||
| 520 | it('failure case - an unreadable check state reads as pending, never success', async () => { | |
| 521 | stubFetch(() => json({ message: 'boom' }, 500)) | |
| 522 | expect(await github().getPullRequestChecksState(9)).toBe('pending') | |
| 523 | }) | |
| 524 | }) | |
| 525 | ||
| 526 | describe('getBuddyBranches API fallback', () => { | |
| 527 | it('success case - paginates branches and epoch-falls-back on commit-date failures', async () => { | |
| 528 | const prov = github() | |
| 529 | ;(prov as any).runCommand = async () => { | |
| 530 | throw new Error('git missing') | |
| 531 | } | |
| 532 | ;(prov as any).apiRequest = async (endpoint: string) => { | |
| 533 | if (endpoint.includes('/branches?')) { | |
| 534 | return [ | |
| 535 | { name: 'buddy/a', commit: { sha: 'sha-a' } }, | |
| 536 | { name: 'buddy/b', commit: { sha: 'sha-b' } }, | |
| 537 | { name: 'feature/c', commit: { sha: 'sha-c' } }, | |
| 538 | ] | |
| 539 | } | |
| 540 | if (endpoint.includes('/commits/sha-a')) | |
| 541 | return { commit: { committer: { date: '2026-01-01T00:00:00Z' } } } | |
| 542 | throw new Error('commit lookup failed') | |
| 543 | } | |
| 544 | ||
| 545 | const branches = await prov.getBuddyBranches() | |
| 546 | ||
| 547 | expect(branches).toEqual([ | |
| 548 | { name: 'buddy/a', sha: 'sha-a', lastCommitDate: new Date('2026-01-01T00:00:00Z') }, | |
| 549 | { name: 'buddy/b', sha: 'sha-b', lastCommitDate: new Date(0) }, | |
| 550 | ]) | |
| 551 | }) | |
| 552 | ||
| 553 | it('failure case - an unlistable repository reads as no buddy branches', async () => { | |
| 554 | const prov = github() | |
| 555 | ;(prov as any).runCommand = async () => { | |
| 556 | throw new Error('git missing') | |
| 557 | } | |
| 558 | ;(prov as any).apiRequest = async () => { | |
| 559 | throw new Error('branch listing failed') | |
| 560 | } | |
| 561 | ||
| 562 | expect(await prov.getBuddyBranches()).toEqual([]) | |
| 563 | }) | |
| 564 | }) | |
| 565 | ||
| 566 | describe('commitChanges via API', () => { | |
| 567 | function providerForCommit(contentsError: Error): { prov: GitHubProvider, endpoints: string[] } { | |
| 568 | const prov = github() | |
| 569 | ;(prov as any).runCommand = async () => { | |
| 570 | throw new Error('git unavailable') | |
| 571 | } | |
| 572 | const endpoints: string[] = [] | |
| 573 | ;(prov as any).apiRequest = async (endpoint: string) => { | |
| 574 | endpoints.push(endpoint) | |
| 575 | if (endpoint.includes('/git/ref/heads/')) | |
| 576 | return { object: { sha: 'base-sha' } } | |
| 577 | if (endpoint.includes('/git/commits/')) | |
| 578 | return { tree: { sha: 'tree-sha' } } | |
| 579 | if (endpoint.includes('/contents/')) | |
| 580 | throw contentsError | |
| 581 | return {} | |
| 582 | } | |
| 583 | return { prov, endpoints } | |
| 584 | } | |
| 585 | ||
| 586 | it('failure case - refuses an update target missing from base', async () => { | |
| 587 | const { prov, endpoints } = providerForCommit(new Error('404 not found')) | |
| 588 | ||
| 589 | const error = await prov | |
| 590 | .commitChanges('buddy/x', 'msg', [{ path: 'package.json', content: '{"name":"y"}', type: 'update' }]) | |
| 591 | .catch((e: unknown) => e) | |
| 592 | ||
| 593 | expect(error).toBeInstanceOf(FileChangeValidationError) | |
| 594 | expect((error as Error).message).toContain('Refusing to create missing update target') | |
| 595 | expect(endpoints.some(e => e.includes('/git/blobs'))).toBe(false) | |
| 596 | }) | |
| 597 | ||
| 598 | it('failure case - a non-404 lookup failure rethrows instead of masquerading as missing', async () => { | |
| 599 | const { prov } = providerForCommit(new Error('500 server error')) | |
| 600 | ||
| 601 | const error = await prov | |
| 602 | .commitChanges('buddy/x', 'msg', [{ path: 'package.json', content: '{"name":"y"}', type: 'update' }]) | |
| 603 | .catch((e: unknown) => e) | |
| 604 | ||
| 605 | expect(error).not.toBeInstanceOf(FileChangeValidationError) | |
| 606 | expect((error as Error).message).toContain('500 server error') | |
| 607 | }) | |
| 608 | }) | |
| 609 | ||
| 610 | describe('getIssues', () => { | |
| 611 | it('success case - filters out pull requests, walks pages, and serves the cache on the second call', async () => { | |
| 612 | const prov = github() | |
| 613 | const calls = stubFetch((call) => { | |
| 614 | if (call.url.includes('&page=1')) { | |
| 615 | const items = Array.from({ length: 100 }, (_, i) => | |
| 616 | issueResponse(i + 1, i === 0 ? { pull_request: { url: 'x' } } : {})) | |
| 617 | return json(items) | |
| 618 | } | |
| 619 | return json([issueResponse(101)]) | |
| 620 | }) | |
| 621 | ||
| 622 | const issues = await prov.getIssues('open') | |
| 623 | ||
| 624 | expect(issues).toHaveLength(100) | |
| 625 | expect(issues.some(issue => issue.number === 1)).toBe(false) | |
| 626 | expect(issues.some(issue => issue.number === 101)).toBe(true) | |
| 627 | expect(calls.some(c => c.url.includes('&page=1'))).toBe(true) | |
| 628 | expect(calls.some(c => c.url.includes('&page=2'))).toBe(true) | |
| 629 | ||
| 630 | const callCount = calls.length | |
| 631 | const again = await prov.getIssues('open') | |
| 632 | expect(again).toHaveLength(100) | |
| 633 | expect(calls).toHaveLength(callCount) | |
| 634 | }) | |
| 635 | }) | |
| 636 | ||
| 637 | describe('updateIssue', () => { | |
| 638 | it('edge case - passes explicit empty arrays through so labels can be cleared', async () => { | |
| 639 | const calls = stubFetch(() => json(issueResponse(3))) | |
| 640 | ||
| 641 | const issue = await github().updateIssue(3, { labels: [], assignees: [] }) | |
| 642 | ||
| 643 | expect(calls[0].method).toBe('PATCH') | |
| 644 | expect(JSON.parse(calls[0].body ?? '')).toEqual({ labels: [], assignees: [] }) | |
| 645 | expect(issue.number).toBe(3) | |
| 646 | expect(issue.author).toBe('octocat') | |
| 647 | }) | |
| 648 | }) | |
| 649 | ||
| 650 | describe('rate-limit retry', () => { | |
| 651 | it('failure case - a rate-limited list retries through the transport with zero delay', async () => { | |
| 652 | let attempt = 0 | |
| 653 | const calls = stubFetch(() => { | |
| 654 | attempt++ | |
| 655 | return attempt === 1 | |
| 656 | ? new Response('', { status: 429, headers: { 'retry-after': '0' } }) | |
| 657 | : json([]) | |
| 658 | }) | |
| 659 | ||
| 660 | const prs = await github().getPullRequests('open') | |
| 661 | ||
| 662 | expect(prs).toEqual([]) | |
| 663 | expect(calls).toHaveLength(2) | |
| 664 | }) | |
| 665 | }) | |
| 666 | ||
| 667 | describe('issue pinning', () => { | |
| 668 | it('failure case - returns false on GraphQL refusal and never throws', async () => { | |
| 669 | stubFetch(call => call.url.includes('/graphql') | |
| 670 | ? json({ errors: [{ message: 'Only 3 issues can be pinned' }] }) | |
| 671 | : json({ node_id: 'I_x' })) | |
| 672 | ||
| 673 | expect(await github().pinIssue(11)).toBe(false) | |
| 674 | }) | |
| 675 | ||
| 676 | it('success case - pins and unpins with the matching mutation', async () => { | |
| 677 | const prov = github() | |
| 678 | const calls = stubFetch(call => call.url.includes('/graphql') | |
| 679 | ? json({ data: {} }) | |
| 680 | : json({ node_id: 'I_x' })) | |
| 681 | ||
| 682 | expect(await prov.pinIssue(11)).toBe(true) | |
| 683 | expect(await prov.unpinIssue(11)).toBe(true) | |
| 684 | ||
| 685 | const bodies = calls.filter(c => c.url.includes('/graphql')).map(c => c.body ?? '') | |
| 686 | expect(bodies[0]).toContain('pinIssue(input') | |
| 687 | expect(bodies[0]).not.toContain('unpinIssue') | |
| 688 | expect(bodies[1]).toContain('unpinIssue(input') | |
| 689 | }) | |
| 690 | ||
| 691 | it('failure case - an unreadable issue reads as not pinned rather than a thrown error', async () => { | |
| 692 | stubFetch(() => json({ message: 'boom' }, 500)) | |
| 693 | expect(await github().pinIssue(11)).toBe(false) | |
| 694 | }) | |
| 695 | }) | |
| 696 | ||
| 697 | describe('branchExists', () => { | |
| 698 | it('success case - an existing ref reads as true', async () => { | |
| 699 | stubFetch(() => json({ ref: 'refs/heads/main' })) | |
| 700 | expect(await github().branchExists('main')).toBe(true) | |
| 701 | }) | |
| 702 | ||
| 703 | it('success case - a 404 reads as absent', async () => { | |
| 704 | stubFetch(() => json({ message: 'Not Found' }, 404)) | |
| 705 | expect(await github().branchExists('gone')).toBe(false) | |
| 706 | }) | |
| 707 | ||
| 708 | it('failure case - any other failure reads as conservative false', async () => { | |
| 709 | stubFetch(() => json({ message: 'boom' }, 500)) | |
| 710 | expect(await github().branchExists('main')).toBe(false) | |
| 711 | }) | |
| 712 | }) | |
| 713 | ||
| 714 | describe('createBranch', () => { | |
| 715 | it('success case - creates the ref from the base branch SHA', async () => { | |
| 716 | const calls = stubFetch(call => call.method === 'POST' | |
| 717 | ? json({}, 201) | |
| 718 | : json({ object: { sha: 'abc' } })) | |
| 719 | ||
| 720 | await github().createBranch('buddy/new', 'main') | |
| 721 | ||
| 722 | const post = calls.find(c => c.method === 'POST') | |
| 723 | expect(post?.url).toBe('https://api.github.test/repos/owner/repo/git/refs') | |
| 724 | expect(JSON.parse(post?.body ?? '')).toEqual({ ref: 'refs/heads/buddy/new', sha: 'abc' }) | |
| 725 | }) | |
| 726 | ||
| 727 | it('failure case - a failing ref creation rejects', async () => { | |
| 728 | stubFetch(call => call.method === 'POST' | |
| 729 | ? json({ message: 'Reference already exists' }, 400) | |
| 730 | : json({ object: { sha: 'abc' } })) | |
| 731 | ||
| 732 | const error = await github().createBranch('buddy/new', 'main').catch((e: unknown) => e) | |
| 733 | expect(error).toBeInstanceOf(GitHubApiError) | |
| 734 | }) | |
| 735 | }) | |
| 736 | }) | |
test/registry-client-http.test.tsadded+711-0
| @@ -0,0 +1,711 @@ | ||
| 1 | import type { BuddyConfig, PackageMetadata } from '../src/types' | |
| 2 | import type { Logger } from '../src/utils/logger' | |
| 3 | import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test' | |
| 4 | import fs from 'node:fs' | |
| 5 | import os from 'node:os' | |
| 6 | import path from 'node:path' | |
| 7 | import process from 'node:process' | |
| 8 | import { RegistryClient } from '../src/registry/registry-client' | |
| 9 | import { PackageRegistryError } from '../src/types' | |
| 10 | ||
| 11 | function stubFetch(impl: (input?: unknown, init?: unknown) => Promise<Response>) { | |
| 12 | return spyOn(globalThis, 'fetch').mockImplementation(impl as unknown as typeof fetch) | |
| 13 | } | |
| 14 | ||
| 15 | /** | |
| 16 | * Tests for the real HTTP and subprocess paths of RegistryClient. | |
| 17 | * | |
| 18 | * Every network call is served by a `spyOn(globalThis, 'fetch')` stub (the | |
| 19 | * preload guard throws on anything unstubbed), and `runCommand` is spied per | |
| 20 | * instance so no test but the dedicated runCommand suite spawns a process. | |
| 21 | */ | |
| 22 | ||
| 23 | const NPM_REGISTRY = 'https://registry.example.test' | |
| 24 | const COMPOSER_REGISTRY = 'https://packagist.example.test' | |
| 25 | const GITHUB_API = 'https://ghe.example.test/api/v3' | |
| 26 | ||
| 27 | /** Env vars that would otherwise leak real endpoints or tokens into tests. */ | |
| 28 | const ENV_VARS = [ | |
| 29 | 'BUDDY_TOKEN', | |
| 30 | 'GITHUB_TOKEN', | |
| 31 | 'GH_TOKEN', | |
| 32 | 'GITHUB_API_URL', | |
| 33 | 'NPM_CONFIG_REGISTRY', | |
| 34 | 'COMPOSER_REGISTRY_URL', | |
| 35 | ] as const | |
| 36 | ||
| 37 | function makeLogger(): Logger { | |
| 38 | return { | |
| 39 | info: mock(), | |
| 40 | warn: mock(), | |
| 41 | error: mock(), | |
| 42 | success: mock(), | |
| 43 | debug: mock(), | |
| 44 | } as unknown as Logger | |
| 45 | } | |
| 46 | ||
| 47 | function jsonResponse(body: unknown, status = 200): Response { | |
| 48 | return new Response(JSON.stringify(body), { | |
| 49 | status, | |
| 50 | headers: { 'content-type': 'application/json' }, | |
| 51 | }) | |
| 52 | } | |
| 53 | ||
| 54 | function baseConfig(overrides: Partial<BuddyConfig> = {}): BuddyConfig { | |
| 55 | return { | |
| 56 | registries: { npm: NPM_REGISTRY, composer: COMPOSER_REGISTRY }, | |
| 57 | ...overrides, | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | describe('RegistryClient HTTP behavior', () => { | |
| 62 | let testDir: string | |
| 63 | let mockLogger: Logger | |
| 64 | let fetchSpy: any | |
| 65 | let savedEnv: Record<string, string | undefined> | |
| 66 | ||
| 67 | function makeClient(config: BuddyConfig | undefined = baseConfig(), logger: Logger = mockLogger): RegistryClient { | |
| 68 | return new RegistryClient(testDir, logger, config) | |
| 69 | } | |
| 70 | ||
| 71 | beforeEach(() => { | |
| 72 | testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'buddy-test-')) | |
| 73 | mockLogger = makeLogger() | |
| 74 | ||
| 75 | savedEnv = {} | |
| 76 | for (const key of ENV_VARS) { | |
| 77 | savedEnv[key] = process.env[key] | |
| 78 | delete process.env[key] | |
| 79 | } | |
| 80 | }) | |
| 81 | ||
| 82 | afterEach(() => { | |
| 83 | fetchSpy?.mockRestore?.() | |
| 84 | fetchSpy = undefined | |
| 85 | ||
| 86 | for (const key of ENV_VARS) { | |
| 87 | if (savedEnv[key] === undefined) | |
| 88 | delete process.env[key] | |
| 89 | else | |
| 90 | process.env[key] = savedEnv[key] | |
| 91 | } | |
| 92 | ||
| 93 | if (fs.existsSync(testDir)) | |
| 94 | fs.rmSync(testDir, { recursive: true, force: true }) | |
| 95 | }) | |
| 96 | ||
| 97 | describe('getPackageMetadata', () => { | |
| 98 | it('maps a full packument into PackageMetadata, unwrapping repository/license/author objects', async () => { | |
| 99 | const packument = { | |
| 100 | 'name': 'demo-pkg', | |
| 101 | 'dist-tags': { latest: '2.0.0' }, | |
| 102 | 'versions': { | |
| 103 | '1.0.0': {}, | |
| 104 | '2.0.0': { | |
| 105 | name: 'demo-pkg', | |
| 106 | description: 'Latest description', | |
| 107 | homepage: 'https://demo.example.test', | |
| 108 | repository: { url: 'git+https://github.com/demo/demo-pkg.git' }, | |
| 109 | license: { type: 'MIT' }, | |
| 110 | author: { name: 'Demo Author' }, | |
| 111 | keywords: ['demo'], | |
| 112 | dependencies: { dep: '^1.0.0' }, | |
| 113 | devDependencies: { devdep: '^2.0.0' }, | |
| 114 | peerDependencies: { peerdep: '^3.0.0' }, | |
| 115 | }, | |
| 116 | }, | |
| 117 | } | |
| 118 | ||
| 119 | fetchSpy = stubFetch(async () => jsonResponse(packument)) | |
| 120 | ||
| 121 | const client = makeClient() | |
| 122 | const metadata = await client.getPackageMetadata('demo-pkg') | |
| 123 | ||
| 124 | expect(fetchSpy).toHaveBeenCalledTimes(1) | |
| 125 | expect(String(fetchSpy.mock.calls[0][0])).toBe(`${NPM_REGISTRY}/demo-pkg`) | |
| 126 | ||
| 127 | expect(metadata).toBeDefined() | |
| 128 | expect(metadata!.name).toBe('demo-pkg') | |
| 129 | expect(metadata!.description).toBe('Latest description') | |
| 130 | expect(metadata!.repository).toBe('git+https://github.com/demo/demo-pkg.git') | |
| 131 | expect(metadata!.homepage).toBe('https://demo.example.test') | |
| 132 | expect(metadata!.license).toBe('MIT') | |
| 133 | expect(metadata!.author).toBe('Demo Author') | |
| 134 | expect(metadata!.keywords).toEqual(['demo']) | |
| 135 | expect(metadata!.latestVersion).toBe('2.0.0') | |
| 136 | expect(metadata!.versions).toEqual(['1.0.0', '2.0.0']) | |
| 137 | expect(metadata!.dependencies).toEqual({ dep: '^1.0.0' }) | |
| 138 | expect(metadata!.devDependencies).toEqual({ devdep: '^2.0.0' }) | |
| 139 | expect(metadata!.peerDependencies).toEqual({ peerdep: '^3.0.0' }) | |
| 140 | }) | |
| 141 | ||
| 142 | it('falls back to packument-root fields and last version key when dist-tags and the latest manifest are missing', async () => { | |
| 143 | const packument = { | |
| 144 | description: 'Root description', | |
| 145 | homepage: 'https://root.example.test', | |
| 146 | versions: { '1.0.0': {}, '1.1.0': {} }, | |
| 147 | } | |
| 148 | ||
| 149 | fetchSpy = stubFetch(async () => jsonResponse(packument)) | |
| 150 | ||
| 151 | const metadata = await makeClient().getPackageMetadata('rooty-pkg') | |
| 152 | ||
| 153 | expect(metadata).toBeDefined() | |
| 154 | expect(metadata!.latestVersion).toBe('1.1.0') | |
| 155 | expect(metadata!.description).toBe('Root description') | |
| 156 | expect(metadata!.homepage).toBe('https://root.example.test') | |
| 157 | }) | |
| 158 | ||
| 159 | it('refuses shell-unsafe package names before any network call', async () => { | |
| 160 | fetchSpy = stubFetch(async () => { | |
| 161 | throw new Error('unexpected network call') | |
| 162 | }) | |
| 163 | ||
| 164 | const client = makeClient() | |
| 165 | ||
| 166 | const metadata = await client.getPackageMetadata('foo; rm -rf /') | |
| 167 | expect(metadata).toBeUndefined() | |
| 168 | expect(mockLogger.warn).toHaveBeenCalledWith( | |
| 169 | 'Failed to get metadata for foo; rm -rf /:', | |
| 170 | expect.any(PackageRegistryError), | |
| 171 | ) | |
| 172 | ||
| 173 | const exists = await client.packageExists('--cwd=/tmp') | |
| 174 | expect(exists).toBe(false) | |
| 175 | ||
| 176 | expect(fetchSpy).not.toHaveBeenCalled() | |
| 177 | }) | |
| 178 | }) | |
| 179 | ||
| 180 | describe('packument caching and error handling', () => { | |
| 181 | it('memoizes the packument across metadata, latest-version and release-date lookups; clearCache forces a refetch', async () => { | |
| 182 | const packument = { | |
| 183 | 'name': 'cached-pkg', | |
| 184 | 'dist-tags': { latest: '1.0.0' }, | |
| 185 | 'versions': { '1.0.0': {} }, | |
| 186 | 'time': { '1.0.0': '2024-01-05T00:00:00.000Z' }, | |
| 187 | } | |
| 188 | ||
| 189 | fetchSpy = stubFetch(async () => jsonResponse(packument)) | |
| 190 | ||
| 191 | const client = makeClient() | |
| 192 | await client.getPackageMetadata('cached-pkg') | |
| 193 | await client.getLatestVersion('cached-pkg') | |
| 194 | const releaseDate = await client.getPackageVersionReleaseDate('cached-pkg', '1.0.0') | |
| 195 | ||
| 196 | expect(releaseDate?.toISOString()).toBe('2024-01-05T00:00:00.000Z') | |
| 197 | expect(fetchSpy).toHaveBeenCalledTimes(1) | |
| 198 | ||
| 199 | client.clearCache() | |
| 200 | await client.getLatestVersion('cached-pkg') | |
| 201 | expect(fetchSpy).toHaveBeenCalledTimes(2) | |
| 202 | }) | |
| 203 | ||
| 204 | it('treats 404 as an ordinary miss with a debug log, and caches the null result', async () => { | |
| 205 | fetchSpy = stubFetch(async () => new Response('not found', { status: 404 })) | |
| 206 | ||
| 207 | const client = makeClient() | |
| 208 | expect(await client.packageExists('missing-pkg')).toBe(false) | |
| 209 | expect(mockLogger.debug).toHaveBeenCalledWith('No registry data for missing-pkg: HTTP 404') | |
| 210 | expect(mockLogger.warn).not.toHaveBeenCalled() | |
| 211 | ||
| 212 | // The failure is cached as null, so a second ask costs no request. | |
| 213 | expect(await client.packageExists('missing-pkg')).toBe(false) | |
| 214 | expect(fetchSpy).toHaveBeenCalledTimes(1) | |
| 215 | }) | |
| 216 | ||
| 217 | it('warns on non-404 HTTP errors', async () => { | |
| 218 | // 403 without rate-limit headers is non-transient, so no retries occur. | |
| 219 | fetchSpy = stubFetch(async () => new Response('forbidden', { status: 403 })) | |
| 220 | ||
| 221 | const client = makeClient() | |
| 222 | expect(await client.packageExists('gone-pkg')).toBe(false) | |
| 223 | expect(mockLogger.warn).toHaveBeenCalledWith('No registry data for gone-pkg: HTTP 403') | |
| 224 | expect(fetchSpy).toHaveBeenCalledTimes(1) | |
| 225 | }) | |
| 226 | }) | |
| 227 | ||
| 228 | describe('getLatestVersion', () => { | |
| 229 | it('prefers the latest dist-tag and honours includePrerelease', async () => { | |
| 230 | const packument = { | |
| 231 | 'dist-tags': { latest: '2.0.0-beta.1' }, | |
| 232 | 'versions': { '1.0.0': {}, '1.1.0': {}, '2.0.0-beta.1': {} }, | |
| 233 | } | |
| 234 | ||
| 235 | fetchSpy = stubFetch(async () => jsonResponse(packument)) | |
| 236 | ||
| 237 | // Default config: the prerelease dist-tag is skipped, highest stable wins. | |
| 238 | const stableClient = makeClient() | |
| 239 | expect(await stableClient.getLatestVersion('pre-pkg')).toBe('1.1.0') | |
| 240 | ||
| 241 | // Fresh client (fresh cache) with prereleases allowed: dist-tag wins. | |
| 242 | const preClient = makeClient(baseConfig({ | |
| 243 | packages: { strategy: 'all', includePrerelease: true }, | |
| 244 | })) | |
| 245 | expect(await preClient.getLatestVersion('pre-pkg')).toBe('2.0.0-beta.1') | |
| 246 | }) | |
| 247 | ||
| 248 | it('yields null when only prereleases exist and prereleases are excluded', async () => { | |
| 249 | const packument = { | |
| 250 | versions: { '1.0.0-beta.1': {}, '1.0.0-rc.1': {} }, | |
| 251 | } | |
| 252 | ||
| 253 | fetchSpy = stubFetch(async () => jsonResponse(packument)) | |
| 254 | ||
| 255 | expect(await makeClient().getLatestVersion('only-pre-pkg')).toBeNull() | |
| 256 | }) | |
| 257 | }) | |
| 258 | ||
| 259 | describe('runBunOutdated', () => { | |
| 260 | it('parses object-keyed bun outdated --json output with nested {version} fields', async () => { | |
| 261 | const client = makeClient() | |
| 262 | const runCommandSpy = spyOn(client as any, 'runCommand').mockResolvedValueOnce(JSON.stringify({ | |
| 263 | 'react': { current: { version: '18.0.0 ' }, latest: '18.2.0', workspace: 'apps/web' }, | |
| 264 | 'half-formed': { current: '1.0.0' }, | |
| 265 | })) | |
| 266 | ||
| 267 | const results = await (client as any).runBunOutdated() | |
| 268 | ||
| 269 | expect(runCommandSpy).toHaveBeenCalledWith('bun', ['outdated', '--json']) | |
| 270 | expect(results).toEqual([ | |
| 271 | { | |
| 272 | name: 'react', | |
| 273 | current: '18.0.0', | |
| 274 | update: '18.2.0', | |
| 275 | latest: '18.2.0', | |
| 276 | workspace: 'apps/web', | |
| 277 | }, | |
| 278 | ]) | |
| 279 | }) | |
| 280 | ||
| 281 | it('falls back to table parsing when --json output is not JSON, stripping ANSI codes and (dev) suffixes', async () => { | |
| 282 | const esc = String.fromCharCode(27) | |
| 283 | const table = [ | |
| 284 | '│ Package │ Current │ Update │ Latest │ Workspace │', | |
| 285 | '├─────────┼─────────┼────────┼────────┼───────────┤', | |
| 286 | `│ ${esc}[31mtypescript (dev)${esc}[0m │ 5.0.0 │ 5.1.0 │ 5.4.0 │ web │`, | |
| 287 | '└─────────┴─────────┴────────┴────────┴───────────┘', | |
| 288 | ].join('\n') | |
| 289 | ||
| 290 | const client = makeClient() | |
| 291 | const runCommandSpy = spyOn(client as any, 'runCommand') | |
| 292 | .mockResolvedValueOnce('error: unknown flag --json') | |
| 293 | .mockResolvedValueOnce(table) | |
| 294 | ||
| 295 | const results = await (client as any).runBunOutdated() | |
| 296 | ||
| 297 | expect(runCommandSpy).toHaveBeenCalledTimes(2) | |
| 298 | expect(runCommandSpy.mock.calls[0][1]).toEqual(['outdated', '--json']) | |
| 299 | expect(runCommandSpy.mock.calls[1][1]).toEqual(['outdated']) | |
| 300 | expect(results).toEqual([ | |
| 301 | { | |
| 302 | name: 'typescript', | |
| 303 | current: '5.0.0', | |
| 304 | update: '5.1.0', | |
| 305 | latest: '5.4.0', | |
| 306 | workspace: 'web', | |
| 307 | }, | |
| 308 | ]) | |
| 309 | }) | |
| 310 | }) | |
| 311 | ||
| 312 | describe('getOutdatedPackages error handling', () => { | |
| 313 | it('wraps a total bun outdated failure in PackageRegistryError', async () => { | |
| 314 | const client = makeClient() | |
| 315 | spyOn(client as any, 'runCommand').mockRejectedValue(new Error('spawn failed')) | |
| 316 | ||
| 317 | const error = await client.getOutdatedPackages().catch((caught: unknown) => caught) | |
| 318 | ||
| 319 | expect(error).toBeInstanceOf(PackageRegistryError) | |
| 320 | expect((error as Error).message).toContain('Failed to check for outdated packages') | |
| 321 | expect((error as Error).message).toContain('spawn failed') | |
| 322 | }) | |
| 323 | }) | |
| 324 | ||
| 325 | describe('getUpdatesForPackages', () => { | |
| 326 | it('passes validated filter tokens through to bun outdated', async () => { | |
| 327 | const client = makeClient() | |
| 328 | const runCommandSpy = spyOn(client as any, 'runCommand').mockResolvedValue('[]') | |
| 329 | ||
| 330 | const updates = await client.getUpdatesForPackages(['react', '@types/node']) | |
| 331 | ||
| 332 | expect(updates).toEqual([]) | |
| 333 | expect(runCommandSpy).toHaveBeenCalledWith('bun', ['outdated', '--json', 'react', '@types/node']) | |
| 334 | }) | |
| 335 | ||
| 336 | it('rejects flag injection before any command runs', async () => { | |
| 337 | const client = makeClient() | |
| 338 | const runCommandSpy = spyOn(client as any, 'runCommand').mockResolvedValue('[]') | |
| 339 | ||
| 340 | const error = await client.getUpdatesForPackages(['--cwd=/tmp']).catch((caught: unknown) => caught) | |
| 341 | ||
| 342 | expect(error).toBeInstanceOf(PackageRegistryError) | |
| 343 | expect((error as Error).message).toContain('untrusted package filter') | |
| 344 | expect(runCommandSpy).not.toHaveBeenCalled() | |
| 345 | }) | |
| 346 | }) | |
| 347 | ||
| 348 | describe('getPackageJsonOutdated', () => { | |
| 349 | it('reports packages behind latest and honours ignore, excludeMajor, filter, and workspace: protocol', async () => { | |
| 350 | fs.writeFileSync(path.join(testDir, 'package.json'), JSON.stringify({ | |
| 351 | dependencies: { | |
| 352 | a: '^1.0.0', | |
| 353 | b: 'workspace:*', | |
| 354 | c: '2.0.0', | |
| 355 | ignored: '^1.0.0', | |
| 356 | }, | |
| 357 | devDependencies: { d: '~3.0.0' }, | |
| 358 | })) | |
| 359 | ||
| 360 | const client = makeClient(baseConfig({ | |
| 361 | packages: { strategy: 'all', ignore: ['ignored'], excludeMajor: true }, | |
| 362 | })) | |
| 363 | const latestSpy = spyOn(client, 'getLatestVersion').mockImplementation( | |
| 364 | async name => ({ a: '1.2.0', c: '2.0.0', d: '4.0.0' } as Record<string, string>)[name] ?? null, | |
| 365 | ) | |
| 366 | ||
| 367 | const results = await (client as any).getPackageJsonOutdated() | |
| 368 | ||
| 369 | // b skipped (workspace:), c up to date, d excluded as major, ignored ignored. | |
| 370 | expect(results).toEqual([ | |
| 371 | { name: 'a', current: '1.0.0', update: '1.2.0', latest: '1.2.0' }, | |
| 372 | ]) | |
| 373 | ||
| 374 | latestSpy.mockClear() | |
| 375 | const filtered = await (client as any).getPackageJsonOutdated('a') | |
| 376 | expect(filtered.map((result: { name: string }) => result.name)).toEqual(['a']) | |
| 377 | expect(latestSpy.mock.calls.map(call => call[0])).toEqual(['a']) | |
| 378 | ||
| 379 | // findPackageLocation reads the same tree. | |
| 380 | expect(await (client as any).findPackageLocation('a')).toBe('package.json') | |
| 381 | expect(await (client as any).findPackageLocation('not-anywhere')).toBeNull() | |
| 382 | }) | |
| 383 | ||
| 384 | it('warns and returns [] when package.json is valid JSON but not an object', async () => { | |
| 385 | fs.writeFileSync(path.join(testDir, 'package.json'), '[1,2,3]') | |
| 386 | ||
| 387 | const results = await (makeClient() as any).getPackageJsonOutdated() | |
| 388 | ||
| 389 | expect(results).toEqual([]) | |
| 390 | expect(mockLogger.warn).toHaveBeenCalledWith( | |
| 391 | 'Failed to check package.json versions:', | |
| 392 | expect.any(PackageRegistryError), | |
| 393 | ) | |
| 394 | }) | |
| 395 | ||
| 396 | it('warns and returns [] when package.json is not JSON at all', async () => { | |
| 397 | fs.writeFileSync(path.join(testDir, 'package.json'), '{invalid json') | |
| 398 | ||
| 399 | const results = await (makeClient() as any).getPackageJsonOutdated() | |
| 400 | ||
| 401 | expect(results).toEqual([]) | |
| 402 | expect(mockLogger.warn).toHaveBeenCalledWith( | |
| 403 | 'Failed to check package.json versions:', | |
| 404 | expect.any(PackageRegistryError), | |
| 405 | ) | |
| 406 | }) | |
| 407 | }) | |
| 408 | ||
| 409 | describe('getUpdatesForWorkspace', () => { | |
| 410 | it('builds workspace-scoped updates with release-notes and changelog links', async () => { | |
| 411 | const table = [ | |
| 412 | '| Package | Current | Update | Latest |', | |
| 413 | '|---------|---------|--------|--------|', | |
| 414 | '| react | 18.0.0 | 18.1.0 | 18.2.0 |', | |
| 415 | ].join('\n') | |
| 416 | ||
| 417 | const client = makeClient() | |
| 418 | const runCommandSpy = spyOn(client as any, 'runCommand').mockResolvedValue(table) | |
| 419 | const metadata: PackageMetadata = { | |
| 420 | name: 'react', | |
| 421 | repository: 'https://github.com/foo/bar.git', | |
| 422 | homepage: 'https://react.dev', | |
| 423 | latestVersion: '18.2.0', | |
| 424 | versions: ['18.0.0', '18.2.0'], | |
| 425 | } | |
| 426 | spyOn(client, 'getPackageMetadata').mockResolvedValue(metadata) | |
| 427 | ||
| 428 | const updates = await client.getUpdatesForWorkspace('my-app') | |
| 429 | ||
| 430 | expect(runCommandSpy).toHaveBeenCalledWith('bun', ['outdated', '--filter', 'my-app']) | |
| 431 | expect(updates).toHaveLength(1) | |
| 432 | expect(updates[0].name).toBe('react') | |
| 433 | expect(updates[0].currentVersion).toBe('18.0.0') | |
| 434 | expect(updates[0].newVersion).toBe('18.2.0') | |
| 435 | expect(updates[0].updateType).toBe('minor') | |
| 436 | expect(updates[0].file).toBe('my-app/package.json') | |
| 437 | expect(updates[0].releaseNotesUrl).toBe('https://github.com/foo/bar/releases') | |
| 438 | expect(updates[0].changelogUrl).toBe('https://github.com/foo/bar/blob/main/CHANGELOG.md') | |
| 439 | expect(updates[0].homepage).toBe('https://react.dev') | |
| 440 | }) | |
| 441 | ||
| 442 | it('degrades to [] with a warning when the command fails', async () => { | |
| 443 | const client = makeClient() | |
| 444 | spyOn(client as any, 'runCommand').mockRejectedValue(new Error('bun exploded')) | |
| 445 | ||
| 446 | const updates = await client.getUpdatesForWorkspace('my-app') | |
| 447 | ||
| 448 | expect(updates).toEqual([]) | |
| 449 | expect(mockLogger.warn).toHaveBeenCalledWith( | |
| 450 | 'Failed to get updates for workspace my-app:', | |
| 451 | expect.any(Error), | |
| 452 | ) | |
| 453 | }) | |
| 454 | ||
| 455 | it('refuses a malformed workspace name without spawning anything', async () => { | |
| 456 | const client = makeClient() | |
| 457 | const runCommandSpy = spyOn(client as any, 'runCommand').mockResolvedValue('') | |
| 458 | ||
| 459 | const updates = await client.getUpdatesForWorkspace('bad name!') | |
| 460 | ||
| 461 | expect(updates).toEqual([]) | |
| 462 | expect(runCommandSpy).not.toHaveBeenCalled() | |
| 463 | }) | |
| 464 | }) | |
| 465 | ||
| 466 | describe('searchPackages', () => { | |
| 467 | it('maps registry search results from the configured registry', async () => { | |
| 468 | fetchSpy = stubFetch(async () => jsonResponse({ | |
| 469 | objects: [ | |
| 470 | { | |
| 471 | package: { | |
| 472 | name: 'react', | |
| 473 | version: '18.2.0', | |
| 474 | description: 'A library', | |
| 475 | keywords: ['ui'], | |
| 476 | }, | |
| 477 | }, | |
| 478 | ], | |
| 479 | })) | |
| 480 | ||
| 481 | const results = await makeClient().searchPackages('react', 5) | |
| 482 | ||
| 483 | expect(String(fetchSpy.mock.calls[0][0])).toBe(`${NPM_REGISTRY}/-/v1/search?text=react&size=5`) | |
| 484 | expect(results).toEqual([ | |
| 485 | { name: 'react', version: '18.2.0', description: 'A library', keywords: ['ui'] }, | |
| 486 | ]) | |
| 487 | }) | |
| 488 | ||
| 489 | it('returns [] with a warning on HTTP failure', async () => { | |
| 490 | fetchSpy = stubFetch(async () => new Response('bad', { status: 400 })) | |
| 491 | ||
| 492 | const results = await makeClient().searchPackages('react') | |
| 493 | ||
| 494 | expect(results).toEqual([]) | |
| 495 | expect(mockLogger.warn).toHaveBeenCalledWith( | |
| 496 | 'Failed to search packages via registry API:', | |
| 497 | expect.any(Error), | |
| 498 | ) | |
| 499 | }) | |
| 500 | ||
| 501 | it('returns [] when the body has no objects array', async () => { | |
| 502 | fetchSpy = stubFetch(async () => jsonResponse({})) | |
| 503 | ||
| 504 | expect(await makeClient().searchPackages('react')).toEqual([]) | |
| 505 | }) | |
| 506 | }) | |
| 507 | ||
| 508 | describe('getPackageVersionReleaseDate', () => { | |
| 509 | it('reads the packument time map and rejects invalid dates', async () => { | |
| 510 | const packument = { | |
| 511 | versions: { '1.2.3': {} }, | |
| 512 | time: { | |
| 513 | '1.2.3': '2024-01-05T00:00:00.000Z', | |
| 514 | '9.9.9': 'not-a-date', | |
| 515 | }, | |
| 516 | } | |
| 517 | ||
| 518 | fetchSpy = stubFetch(async () => jsonResponse(packument)) | |
| 519 | ||
| 520 | const client = makeClient() | |
| 521 | const date = await client.getPackageVersionReleaseDate('dated-pkg', '1.2.3') | |
| 522 | expect(date?.toISOString()).toBe('2024-01-05T00:00:00.000Z') | |
| 523 | ||
| 524 | expect(await client.getPackageVersionReleaseDate('dated-pkg', '5.5.5')).toBeNull() | |
| 525 | expect(await client.getPackageVersionReleaseDate('dated-pkg', '9.9.9')).toBeNull() | |
| 526 | ||
| 527 | // All three reads share the memoized packument. | |
| 528 | expect(fetchSpy).toHaveBeenCalledTimes(1) | |
| 529 | }) | |
| 530 | }) | |
| 531 | ||
| 532 | describe('getGitHubActionReleaseDate', () => { | |
| 533 | const githubConfig = (token?: string): BuddyConfig => baseConfig({ | |
| 534 | repository: { | |
| 535 | provider: 'github', | |
| 536 | owner: 'acme', | |
| 537 | name: 'app', | |
| 538 | token, | |
| 539 | apiUrl: GITHUB_API, | |
| 540 | }, | |
| 541 | }) | |
| 542 | ||
| 543 | it('hits the releases-tag endpoint with auth headers from config', async () => { | |
| 544 | let capturedUrl = '' | |
| 545 | let capturedHeaders: Record<string, string> = {} | |
| 546 | fetchSpy = stubFetch(async (input: any, init?: any) => { | |
| 547 | capturedUrl = String(input) | |
| 548 | capturedHeaders = (init?.headers ?? {}) as Record<string, string> | |
| 549 | return jsonResponse({ published_at: '2024-03-01T12:00:00Z' }) | |
| 550 | }) | |
| 551 | ||
| 552 | const date = await makeClient(githubConfig('cfg-token')) | |
| 553 | .getGitHubActionReleaseDate('actions/checkout', 'v4.1.0') | |
| 554 | ||
| 555 | expect(capturedUrl).toBe(`${GITHUB_API}/repos/actions/checkout/releases/tags/v4.1.0`) | |
| 556 | expect(capturedHeaders.Authorization).toBe('Bearer cfg-token') | |
| 557 | expect(date?.toISOString()).toBe('2024-03-01T12:00:00.000Z') | |
| 558 | }) | |
| 559 | ||
| 560 | it('yields null when the release carries no published_at', async () => { | |
| 561 | fetchSpy = stubFetch(async () => jsonResponse({})) | |
| 562 | ||
| 563 | const date = await makeClient(githubConfig('cfg-token')) | |
| 564 | .getGitHubActionReleaseDate('actions/checkout', 'v4.1.0') | |
| 565 | ||
| 566 | expect(date).toBeNull() | |
| 567 | }) | |
| 568 | ||
| 569 | it('guards malformed action names without any request', async () => { | |
| 570 | fetchSpy = stubFetch(async () => jsonResponse({})) | |
| 571 | ||
| 572 | const date = await makeClient(githubConfig('cfg-token')) | |
| 573 | .getGitHubActionReleaseDate('noslash', 'v1') | |
| 574 | ||
| 575 | expect(date).toBeNull() | |
| 576 | expect(fetchSpy).not.toHaveBeenCalled() | |
| 577 | }) | |
| 578 | ||
| 579 | it('falls through BUDDY_TOKEN, GITHUB_TOKEN, GH_TOKEN, then anonymous', async () => { | |
| 580 | const captured: Array<Record<string, string>> = [] | |
| 581 | fetchSpy = stubFetch(async (_input: any, init?: any) => { | |
| 582 | captured.push((init?.headers ?? {}) as Record<string, string>) | |
| 583 | return jsonResponse({ published_at: '2024-03-01T12:00:00Z' }) | |
| 584 | }) | |
| 585 | ||
| 586 | const client = makeClient(githubConfig()) | |
| 587 | ||
| 588 | process.env.BUDDY_TOKEN = 'buddy-tok' | |
| 589 | process.env.GITHUB_TOKEN = 'gh-tok' | |
| 590 | await client.getGitHubActionReleaseDate('a/b', 'v1') | |
| 591 | ||
| 592 | delete process.env.BUDDY_TOKEN | |
| 593 | await client.getGitHubActionReleaseDate('a/b', 'v1') | |
| 594 | ||
| 595 | delete process.env.GITHUB_TOKEN | |
| 596 | process.env.GH_TOKEN = 'cli-tok' | |
| 597 | await client.getGitHubActionReleaseDate('a/b', 'v1') | |
| 598 | ||
| 599 | delete process.env.GH_TOKEN | |
| 600 | await client.getGitHubActionReleaseDate('a/b', 'v1') | |
| 601 | ||
| 602 | expect(captured[0].Authorization).toBe('Bearer buddy-tok') | |
| 603 | expect(captured[1].Authorization).toBe('Bearer gh-tok') | |
| 604 | expect(captured[2].Authorization).toBe('Bearer cli-tok') | |
| 605 | expect(captured[3].Authorization).toBeUndefined() | |
| 606 | expect(captured[3].Accept).toBe('application/vnd.github.v3+json') | |
| 607 | expect(captured[3]['User-Agent']).toBe('buddy') | |
| 608 | }) | |
| 609 | }) | |
| 610 | ||
| 611 | describe('getComposerPackageReleaseDate', () => { | |
| 612 | it('reads Packagist version timestamps', async () => { | |
| 613 | fetchSpy = stubFetch(async (input: any) => { | |
| 614 | expect(String(input)).toBe(`${COMPOSER_REGISTRY}/packages/laravel/framework.json`) | |
| 615 | return jsonResponse({ | |
| 616 | package: { | |
| 617 | versions: { | |
| 618 | 'v10.0.0': { time: '2023-02-14T10:00:00+00:00' }, | |
| 619 | }, | |
| 620 | }, | |
| 621 | }) | |
| 622 | }) | |
| 623 | ||
| 624 | const client = makeClient() | |
| 625 | const date = await client.getComposerPackageReleaseDate('laravel/framework', 'v10.0.0') | |
| 626 | expect(date?.toISOString()).toBe('2023-02-14T10:00:00.000Z') | |
| 627 | ||
| 628 | expect(await client.getComposerPackageReleaseDate('laravel/framework', 'v9.0.0')).toBeNull() | |
| 629 | }) | |
| 630 | ||
| 631 | it('yields null when Packagist has no record', async () => { | |
| 632 | fetchSpy = stubFetch(async () => new Response('missing', { status: 404 })) | |
| 633 | ||
| 634 | const date = await makeClient().getComposerPackageReleaseDate('unknown/package', 'v1.0.0') | |
| 635 | ||
| 636 | expect(date).toBeNull() | |
| 637 | }) | |
| 638 | }) | |
| 639 | ||
| 640 | describe('minimum release age for docker images', () => { | |
| 641 | it('is conservatively null so minimumReleaseAge allows docker updates', async () => { | |
| 642 | fetchSpy = stubFetch(async () => { | |
| 643 | throw new Error('unexpected network call') | |
| 644 | }) | |
| 645 | ||
| 646 | const client = makeClient(baseConfig({ | |
| 647 | packages: { strategy: 'all', minimumReleaseAge: 1440 }, | |
| 648 | })) | |
| 649 | ||
| 650 | expect(await client.getDockerImageReleaseDate('nginx', '1.25')).toBeNull() | |
| 651 | ||
| 652 | const allowed = await client.meetsMinimumReleaseAge('nginx', '1.25', 'docker-image') | |
| 653 | expect(allowed).toBe(true) | |
| 654 | expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('nginx@1.25')) | |
| 655 | expect(fetchSpy).not.toHaveBeenCalled() | |
| 656 | }) | |
| 657 | }) | |
| 658 | ||
| 659 | describe('runCommand', () => { | |
| 660 | it('captures stdout from a successful command', async () => { | |
| 661 | const output = await (makeClient(undefined) as any).runCommand('bun', ['-e', 'console.log("out")']) | |
| 662 | expect(output).toBe('out\n') | |
| 663 | }) | |
| 664 | ||
| 665 | it('surfaces stderr and the exit code on failure', async () => { | |
| 666 | const error = await (makeClient(undefined) as any) | |
| 667 | .runCommand('bun', ['-e', 'console.error("boom"); process.exit(3)']) | |
| 668 | .catch((caught: unknown) => caught) | |
| 669 | ||
| 670 | expect(error).toBeInstanceOf(Error) | |
| 671 | expect((error as Error).message).toContain('code 3') | |
| 672 | expect((error as Error).message).toContain('boom') | |
| 673 | }) | |
| 674 | ||
| 675 | it('rejects when the binary does not exist', async () => { | |
| 676 | const error = await (makeClient(undefined) as any) | |
| 677 | .runCommand('buddy-definitely-not-a-real-binary-xyz', []) | |
| 678 | .catch((caught: unknown) => caught) | |
| 679 | ||
| 680 | // Depending on runtime, the miss reports via the error event (ENOENT, | |
| 681 | // or Bun's "Executable not found") or via close with a negative exit | |
| 682 | // code — assert the observable message, not the channel. | |
| 683 | expect(error).toBeInstanceOf(Error) | |
| 684 | expect((error as Error).message).toMatch(/ENOENT|code -\d+|Executable not found/) | |
| 685 | }) | |
| 686 | }) | |
| 687 | ||
| 688 | describe('getComposerOutdatedPackages', () => { | |
| 689 | it('returns [] with a warning when composer.json cannot be parsed', async () => { | |
| 690 | const client = makeClient() | |
| 691 | spyOn(client as any, 'runCommand').mockResolvedValueOnce('Composer version 2.5.8') | |
| 692 | ||
| 693 | const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true) | |
| 694 | const readSpy = spyOn(fs, 'readFileSync').mockReturnValue('{invalid json') | |
| 695 | ||
| 696 | try { | |
| 697 | const updates = await client.getComposerOutdatedPackages() | |
| 698 | ||
| 699 | expect(updates).toEqual([]) | |
| 700 | expect(mockLogger.warn).toHaveBeenCalledWith( | |
| 701 | 'Failed to check for outdated Composer packages:', | |
| 702 | expect.any(Error), | |
| 703 | ) | |
| 704 | } | |
| 705 | finally { | |
| 706 | existsSpy.mockRestore() | |
| 707 | readSpy.mockRestore() | |
| 708 | } | |
| 709 | }) | |
| 710 | }) | |
| 711 | }) | |