also looking at this
feat(engines): propose runtime bumps, which were resolved and never proposed
#1428
8 files
+649
-14
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -159,6 +159,7 @@ matches `react` but not `@types/node`. Use `['**']` for every package, or | ||
| 159 | 159 | | `autoMerge` | Allows unattended merge — see the caveat below | |
| 160 | 160 | | `autoMigrate` | Attempts the migration for matching majors | |
| 161 | 161 | | `minimumReleaseAge` | Minutes a version must have been published | |
| 162 | | `engines` | Propose bumps to the runtimes under `engines` (default off) | | |
| 162 | 163 | | `prPriority` | Ordering within `maxPRsPerRun`; higher goes first | |
| 163 | 164 | |
| 164 | 165 | `prPriority` is applied **before** the per-run cap, so a high-priority group |
| @@ -168,6 +168,15 @@ export class Buddy { | ||
| 168 | 168 | // Get outdated Zig dependencies |
| 169 | 169 | const zigStartTime = Date.now() |
| 170 | 170 | const zigUpdates = await this.checkZigForUpdates(packageFiles) |
| 171 | ||
| 172 | // Runtimes declared under `engines`. Both halves of this — resolving a | |
| 173 | // newer runtime and rewriting the constraint — existed and were tested, | |
| 174 | // and no scan step called either, so an engine was listed on the | |
| 175 | // dashboard and never proposed. Opt-in, because bumping one is a | |
| 176 | // deployment decision rather than a dependency one. | |
| 177 | const engineUpdates = this.config.packages?.engines === true | |
| 178 | ? await this.checkEnginesForUpdates(packageFiles) | |
| 179 | : [] | |
| 171 | 180 | this.logger.info(`⏱️ Zig dependency checks took ${Date.now() - zigStartTime}ms (found ${zigUpdates.length} updates)`) |
| 172 | 181 | |
| 173 | 182 | // pnpm catalogs are where the version lives in a workspace that uses |
| @@ -212,6 +221,7 @@ export class Buddy { | ||
| 212 | 221 | ...githubActionsUpdates, |
| 213 | 222 | ...dockerUpdates, |
| 214 | 223 | ...zigUpdates, |
| 224 | ...engineUpdates, | |
| 215 | 225 | ...adapterScan.updates, |
| 216 | 226 | ...catalogScan.updates, |
| 217 | 227 | ] |
| @@ -1403,6 +1413,71 @@ export class Buddy { | ||
| 1403 | 1413 | return deduplicatedUpdates |
| 1404 | 1414 | } |
| 1405 | 1415 | |
| 1416 | /** | |
| 1417 | * Propose bumps to the runtimes declared under `engines`. | |
| 1418 | * | |
| 1419 | * Node and Deno publish releases on GitHub; the package managers publish to | |
| 1420 | * npm. `resolveEngineVersion` knows which is which, and the lookups are the | |
| 1421 | * clients this instance already has, so the npm side shares the registry | |
| 1422 | * cache with everything else in the scan. | |
| 1423 | * | |
| 1424 | * @param packageFiles - Every scanned manifest | |
| 1425 | * @returns One update per engine whose constraint no longer admits the newest release | |
| 1426 | */ | |
| 1427 | private async checkEnginesForUpdates(packageFiles: PackageFile[]): Promise<PackageUpdate[]> { | |
| 1428 | const { bumpEngineConstraint, engineUpdateType, resolveEngineVersion } = await import('./scanner/package-json-extras') | |
| 1429 | const { fetchLatestActionVersion } = await import('./utils/github-actions-parser') | |
| 1430 | ||
| 1431 | const engines = packageFiles.flatMap(file => | |
| 1432 | file.dependencies | |
| 1433 | .filter(dep => dep.type === 'engines') | |
| 1434 | .map(dep => ({ file, dep })), | |
| 1435 | ) | |
| 1436 | ||
| 1437 | if (engines.length === 0) | |
| 1438 | return [] | |
| 1439 | ||
| 1440 | this.logger.info(`⚡ Checking ${engines.length} engine constraint(s)...`) | |
| 1441 | ||
| 1442 | const lookups = { | |
| 1443 | npm: (name: string) => this.registryClient.getLatestVersion(name), | |
| 1444 | // The action fetcher takes `owner/repo` and returns the latest release | |
| 1445 | // tag, which is exactly the shape a runtime's GitHub releases have. | |
| 1446 | github: (repo: string) => fetchLatestActionVersion(repo), | |
| 1447 | } | |
| 1448 | ||
| 1449 | const checks = engines.map(async ({ file, dep }): Promise<PackageUpdate | null> => { | |
| 1450 | try { | |
| 1451 | const latest = await resolveEngineVersion(dep.name, lookups) | |
| 1452 | if (!latest) | |
| 1453 | return null | |
| 1454 | ||
| 1455 | const proposed = bumpEngineConstraint(dep.currentVersion, latest) | |
| 1456 | if (!proposed) { | |
| 1457 | this.logger.debug(`${dep.name} ${dep.currentVersion} already admits ${latest}`) | |
| 1458 | return null | |
| 1459 | } | |
| 1460 | ||
| 1461 | this.logger.info(`Update available: ${dep.name} ${dep.currentVersion} → ${proposed}`) | |
| 1462 | ||
| 1463 | return { | |
| 1464 | name: dep.name, | |
| 1465 | currentVersion: dep.currentVersion, | |
| 1466 | newVersion: proposed, | |
| 1467 | updateType: engineUpdateType(dep.currentVersion, proposed), | |
| 1468 | dependencyType: 'engines', | |
| 1469 | file: file.path, | |
| 1470 | } | |
| 1471 | } | |
| 1472 | catch (error) { | |
| 1473 | this.logger.warn(`Could not check engine ${dep.name}:`, error) | |
| 1474 | return null | |
| 1475 | } | |
| 1476 | }) | |
| 1477 | ||
| 1478 | return (await Promise.all(checks)).filter((update): update is PackageUpdate => update !== null) | |
| 1479 | } | |
| 1480 | ||
| 1406 | 1481 | /** |
| 1407 | 1482 | * Check Dockerfiles for updates |
| 1408 | 1483 | */ |
| @@ -1601,11 +1676,38 @@ export class Buddy { | ||
| 1601 | 1676 | // Handle package.json updates - only for actual package.json files, not dependency or GitHub Actions files |
| 1602 | 1677 | const packageJsonUpdates = updates.filter(update => |
| 1603 | 1678 | update.file.endsWith('package.json') |
| 1679 | && update.dependencyType !== 'engines' | |
| 1604 | 1680 | && !update.file.includes('.yaml') |
| 1605 | 1681 | && !update.file.includes('.yml') |
| 1606 | 1682 | && !update.file.includes('.github/workflows/'), |
| 1607 | 1683 | ) |
| 1608 | 1684 | |
| 1685 | // Engine constraints live under `engines`, which the dependency-section | |
| 1686 | // loop below never looks in — an engine update sent through it logged | |
| 1687 | // "not found" and wrote nothing. | |
| 1688 | const engineUpdates = updates.filter(update => update.dependencyType === 'engines') | |
| 1689 | if (engineUpdates.length > 0) { | |
| 1690 | const { applyEngineUpdates } = await import('./scanner/package-json-extras') | |
| 1691 | const byFile = new Map<string, PackageUpdate[]>() | |
| 1692 | for (const update of engineUpdates) | |
| 1693 | byFile.set(update.file, [...(byFile.get(update.file) ?? []), update]) | |
| 1694 | ||
| 1695 | for (const [path, fileEngines] of byFile) { | |
| 1696 | try { | |
| 1697 | const original = fs.readFileSync(path, 'utf-8') | |
| 1698 | const content = applyEngineUpdates(original, fileEngines) | |
| 1699 | if (content === original) { | |
| 1700 | this.logger.warn(`⚠️ No engine changes written to ${path}`) | |
| 1701 | continue | |
| 1702 | } | |
| 1703 | fileUpdates.push({ path, content, type: 'update' }) | |
| 1704 | } | |
| 1705 | catch (error) { | |
| 1706 | this.logger.warn(`Failed to update engines in ${path}:`, error) | |
| 1707 | } | |
| 1708 | } | |
| 1709 | } | |
| 1710 | ||
| 1609 | 1711 | // Group package.json updates by file |
| 1610 | 1712 | const updatesByPackageFile = new Map<string, PackageUpdate[]>() |
| 1611 | 1713 | for (const update of packageJsonUpdates) { |
| @@ -290,7 +290,7 @@ function validatePackages(issues: ConfigIssue[], config: BuddyConfig): void { | ||
| 290 | 290 | checkStringArray(issues, 'packages.minimumReleaseAgeExclude', packages.minimumReleaseAgeExclude) |
| 291 | 291 | checkNumber(issues, 'packages.minimumReleaseAge', packages.minimumReleaseAge, { min: 0 }) |
| 292 | 292 | |
| 293 | for (const key of ['includePrerelease', 'excludeMajor', 'respectLatest'] as const) { | |
| 293 | for (const key of ['includePrerelease', 'excludeMajor', 'respectLatest', 'engines'] as const) { | |
| 294 | 294 | const value = packages[key] |
| 295 | 295 | if (value !== undefined && typeof value !== 'boolean') |
| 296 | 296 | issues.push({ path: `packages.${key}`, message: `expected a boolean, got ${quote(value)}` }) |
| @@ -1,4 +1,4 @@ | ||
| 1 | import type { Dependency } from '../types' | |
| 1 | import type { Dependency, PackageUpdate } from '../types' | |
| 2 | 2 | |
| 3 | 3 | /** Runtimes buddy knows how to resolve a version for. */ |
| 4 | 4 | export const KNOWN_ENGINES: Record<string, { registry: 'npm' | 'github', source: string }> = { |
| @@ -217,3 +217,69 @@ export function bumpEngineConstraint(constraint: string, latest: string): string | ||
| 217 | 217 | |
| 218 | 218 | return `${operator}${truncated.join('.')}` |
| 219 | 219 | } |
| 220 | ||
| 221 | /** | |
| 222 | * Classify an engine bump by the first component that changed. | |
| 223 | * | |
| 224 | * Constraints carry operators and are often imprecise (`>=20`), so semver | |
| 225 | * ordering cannot be applied to them directly. Comparing the numeric parts | |
| 226 | * positionally is what the constraint means anyway. | |
| 227 | * | |
| 228 | * @param current - The declared constraint | |
| 229 | * @param next - The proposed constraint | |
| 230 | * @returns The semver impact of the change | |
| 231 | * @example | |
| 232 | * ```ts | |
| 233 | * engineUpdateType('>=20', '>=22') // 'major' | |
| 234 | * engineUpdateType('>=20.5', '>=20.9') // 'minor' | |
| 235 | * ``` | |
| 236 | */ | |
| 237 | export function engineUpdateType(current: string, next: string): PackageUpdate['updateType'] { | |
| 238 | const parts = (value: string): number[] => (/(\d+(?:\.\d+)*)/.exec(value)?.[1] ?? '0').split('.').map(Number) | |
| 239 | const before = parts(current) | |
| 240 | const after = parts(next) | |
| 241 | ||
| 242 | if ((after[0] ?? 0) !== (before[0] ?? 0)) | |
| 243 | return 'major' | |
| 244 | if ((after[1] ?? 0) !== (before[1] ?? 0)) | |
| 245 | return 'minor' | |
| 246 | return 'patch' | |
| 247 | } | |
| 248 | ||
| 249 | /** | |
| 250 | * Rewrite engine constraints in a package.json's text. | |
| 251 | * | |
| 252 | * String replacement rather than parse-and-serialise, for the same reason the | |
| 253 | * dependency writer does it: the file's indentation, key order and trailing | |
| 254 | * newline are somebody's choices, and re-serialising would silently discard | |
| 255 | * them in a pull request that claims to touch one line. | |
| 256 | * | |
| 257 | * Scoped to the `engines` object, so an engine whose name is also a dependency | |
| 258 | * — `npm`, `yarn` — is not rewritten in `devDependencies` by mistake. | |
| 259 | * | |
| 260 | * @param content - Current package.json text | |
| 261 | * @param updates - Engine updates to apply | |
| 262 | * @returns The rewritten text, unchanged when nothing matched | |
| 263 | */ | |
| 264 | export function applyEngineUpdates(content: string, updates: PackageUpdate[]): string { | |
| 265 | const block = /("engines"\s*:\s*\{)([\s\S]*?)(\})/.exec(content) | |
| 266 | if (!block) | |
| 267 | return content | |
| 268 | ||
| 269 | const [whole, open, body, close] = block | |
| 270 | let next = body | |
| 271 | ||
| 272 | for (const update of updates) { | |
| 273 | if (update.dependencyType !== 'engines') | |
| 274 | continue | |
| 275 | ||
| 276 | const name = update.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | |
| 277 | const current = update.currentVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | |
| 278 | next = next.replace( | |
| 279 | new RegExp(`("${name}"\\s*:\\s*")${current}(")`), | |
| 280 | `$1${update.newVersion}$2`, | |
| 281 | ) | |
| 282 | } | |
| 283 | ||
| 284 | return next === body ? content : content.replace(whole, `${open}${next}${close}`) | |
| 285 | } | |
| @@ -135,6 +135,15 @@ export interface BuddyConfig { | ||
| 135 | 135 | excludeMajor?: boolean |
| 136 | 136 | /** Respect "latest" and "*" version indicators (default: true) */ |
| 137 | 137 | respectLatest?: boolean |
| 138 | /** | |
| 139 | * Propose bumps to the runtimes declared under `engines` (default: false). | |
| 140 | * | |
| 141 | * Off by default because an engine constraint is a deployment decision | |
| 142 | * rather than a dependency one: raising `node: '>=20'` to `'>=22'` is a | |
| 143 | * statement about every environment the project runs in, not just the | |
| 144 | * lockfile. The constraint's shape is preserved — a floor stays a floor. | |
| 145 | */ | |
| 146 | engines?: boolean | |
| 138 | 147 | /** Minimum age in minutes that a package version must have before installation (default: 0) */ |
| 139 | 148 | minimumReleaseAge?: number |
| 140 | 149 | /** Package names to exclude from minimum release age requirement */ |
| @@ -0,0 +1,224 @@ | ||
| 1 | import type { PackageFile, PackageUpdate } from '../src/types' | |
| 2 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 3 | import { promises as fs } from 'node:fs' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { Buddy } from '../src/buddy' | |
| 7 | import { validateConfig } from '../src/config-validation' | |
| 8 | import { applyEngineUpdates, engineUpdateType } from '../src/scanner/package-json-extras' | |
| 9 | ||
| 10 | const realFetch = globalThis.fetch | |
| 11 | ||
| 12 | afterEach(() => { | |
| 13 | globalThis.fetch = realFetch | |
| 14 | }) | |
| 15 | ||
| 16 | const MANIFEST = `{ | |
| 17 | "name": "app", | |
| 18 | "engines": { | |
| 19 | "node": ">=20", | |
| 20 | "pnpm": "^9.0.0" | |
| 21 | }, | |
| 22 | "devDependencies": { | |
| 23 | "pnpm": "^9.0.0" | |
| 24 | } | |
| 25 | } | |
| 26 | ` | |
| 27 | ||
| 28 | function engine(name: string, current: string, next: string): PackageUpdate { | |
| 29 | return { | |
| 30 | name, | |
| 31 | currentVersion: current, | |
| 32 | newVersion: next, | |
| 33 | updateType: engineUpdateType(current, next), | |
| 34 | dependencyType: 'engines', | |
| 35 | file: 'package.json', | |
| 36 | } as PackageUpdate | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * `resolveEngineVersion` and `bumpEngineConstraint` were written, tested and | |
| 41 | * exported, and no scan step called either — so an engine was listed on the | |
| 42 | * dashboard and never proposed. Had one been, the package.json writer only | |
| 43 | * looks in the four dependency sections, so it would have logged "not found" | |
| 44 | * and written nothing. | |
| 45 | */ | |
| 46 | describe('engine update type', () => { | |
| 47 | it('success case - classifies by the first component that moved', () => { | |
| 48 | expect(engineUpdateType('>=20', '>=22')).toBe('major') | |
| 49 | expect(engineUpdateType('>=20.5', '>=20.9')).toBe('minor') | |
| 50 | expect(engineUpdateType('^1.2.3', '^1.2.4')).toBe('patch') | |
| 51 | }) | |
| 52 | }) | |
| 53 | ||
| 54 | describe('rewriting engine constraints', () => { | |
| 55 | it('success case - rewrites the constraint and nothing else', () => { | |
| 56 | const result = applyEngineUpdates(MANIFEST, [engine('node', '>=20', '>=22')]) | |
| 57 | ||
| 58 | expect(result).toContain('"node": ">=22"') | |
| 59 | // Indentation, key order and the trailing newline are the author's. | |
| 60 | expect(result.split('\n').length).toBe(MANIFEST.split('\n').length) | |
| 61 | expect(result.endsWith('\n')).toBe(true) | |
| 62 | }) | |
| 63 | ||
| 64 | it('failure case - a name shared with a dependency is not touched there', () => { | |
| 65 | // `pnpm` is both an engine and a devDependency here. Only the engine | |
| 66 | // moves; rewriting the devDependency would be a different update. | |
| 67 | const result = applyEngineUpdates(MANIFEST, [engine('pnpm', '^9.0.0', '^10.0.0')]) | |
| 68 | ||
| 69 | expect(result).toContain('"engines": {\n "node": ">=20",\n "pnpm": "^10.0.0"') | |
| 70 | expect(result).toContain('"devDependencies": {\n "pnpm": "^9.0.0"') | |
| 71 | }) | |
| 72 | ||
| 73 | it('success case - applies several engines in one pass', () => { | |
| 74 | const result = applyEngineUpdates(MANIFEST, [ | |
| 75 | engine('node', '>=20', '>=22'), | |
| 76 | engine('pnpm', '^9.0.0', '^10.0.0'), | |
| 77 | ]) | |
| 78 | ||
| 79 | expect(result).toContain('"node": ">=22"') | |
| 80 | expect(result).toContain('"pnpm": "^10.0.0"') | |
| 81 | }) | |
| 82 | ||
| 83 | it('edge case - an unmatched constraint leaves the file identical', () => { | |
| 84 | expect(applyEngineUpdates(MANIFEST, [engine('node', '>=18', '>=22')])).toBe(MANIFEST) | |
| 85 | }) | |
| 86 | ||
| 87 | it('edge case - a manifest with no engines block is untouched', () => { | |
| 88 | const bare = '{\n "name": "app"\n}\n' | |
| 89 | expect(applyEngineUpdates(bare, [engine('node', '>=20', '>=22')])).toBe(bare) | |
| 90 | }) | |
| 91 | }) | |
| 92 | ||
| 93 | describe('checking engines for updates', () => { | |
| 94 | function manifestWith(engines: Record<string, string>): PackageFile { | |
| 95 | return { | |
| 96 | path: 'package.json', | |
| 97 | type: 'package.json', | |
| 98 | content: '', | |
| 99 | dependencies: Object.entries(engines).map(([name, currentVersion]) => ({ | |
| 100 | name, | |
| 101 | currentVersion, | |
| 102 | type: 'engines' as const, | |
| 103 | file: 'package.json', | |
| 104 | })), | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | function buddyWithRegistry(latest: Record<string, string | null>): Buddy { | |
| 109 | const buddy = new Buddy({ packages: { strategy: 'all', engines: true } }, process.cwd()) | |
| 110 | ;(buddy as never as { registryClient: { getLatestVersion: (n: string) => Promise<string | null> } }) | |
| 111 | .registryClient.getLatestVersion = async name => latest[name] ?? null | |
| 112 | return buddy | |
| 113 | } | |
| 114 | ||
| 115 | async function check(buddy: Buddy, files: PackageFile[]): Promise<PackageUpdate[]> { | |
| 116 | return (buddy as never as { checkEnginesForUpdates: (f: PackageFile[]) => Promise<PackageUpdate[]> }) | |
| 117 | .checkEnginesForUpdates(files) | |
| 118 | } | |
| 119 | ||
| 120 | it('success case - an npm-published runtime is resolved through the registry', async () => { | |
| 121 | const updates = await check(buddyWithRegistry({ pnpm: '10.4.1' }), [manifestWith({ pnpm: '^9.0.0' })]) | |
| 122 | ||
| 123 | expect(updates).toEqual([ | |
| 124 | expect.objectContaining({ | |
| 125 | name: 'pnpm', | |
| 126 | currentVersion: '^9.0.0', | |
| 127 | newVersion: '^10.4.1', | |
| 128 | updateType: 'major', | |
| 129 | dependencyType: 'engines', | |
| 130 | }), | |
| 131 | ]) | |
| 132 | }) | |
| 133 | ||
| 134 | it('success case - node is resolved through its GitHub releases', async () => { | |
| 135 | globalThis.fetch = (async (input: string) => { | |
| 136 | expect(String(input)).toContain('/repos/nodejs/node/releases/latest') | |
| 137 | return new Response(JSON.stringify({ tag_name: 'v22.3.0' }), { status: 200 }) | |
| 138 | }) as unknown as typeof fetch | |
| 139 | ||
| 140 | const updates = await check(buddyWithRegistry({}), [manifestWith({ node: '>=20' })]) | |
| 141 | ||
| 142 | expect(updates[0]).toMatchObject({ name: 'node', newVersion: '>=22' }) | |
| 143 | }) | |
| 144 | ||
| 145 | it('success case - the constraint keeps its shape', async () => { | |
| 146 | // A floor stays a floor at the author's precision: `>=20` becomes `>=22`, | |
| 147 | // never `22.3.0`, which would break every contributor on another patch. | |
| 148 | globalThis.fetch = (async () => | |
| 149 | new Response(JSON.stringify({ tag_name: 'v22.3.0' }), { status: 200 })) as unknown as typeof fetch | |
| 150 | ||
| 151 | const updates = await check(buddyWithRegistry({}), [manifestWith({ node: '>=20.5' })]) | |
| 152 | ||
| 153 | expect(updates[0]?.newVersion).toBe('>=22.3') | |
| 154 | }) | |
| 155 | ||
| 156 | it('edge case - a constraint already at the latest yields nothing', async () => { | |
| 157 | // Compared at the author's precision: a floor of `>=9` is not moved by a | |
| 158 | // 9.x release, only by 10. | |
| 159 | const updates = await check(buddyWithRegistry({ pnpm: '9.1.0' }), [manifestWith({ pnpm: '>=9' })]) | |
| 160 | ||
| 161 | expect(updates).toEqual([]) | |
| 162 | }) | |
| 163 | ||
| 164 | it('success case - a precise caret constraint follows the latest within its major', async () => { | |
| 165 | // `^9.0.0` admits 9.1.0 already, but the floor is what the author wrote, | |
| 166 | // and keeping it current is the point — the same way `^9.0.0` on a | |
| 167 | // dependency is bumped to `^9.1.0`. | |
| 168 | const updates = await check(buddyWithRegistry({ pnpm: '9.1.0' }), [manifestWith({ pnpm: '^9.0.0' })]) | |
| 169 | ||
| 170 | expect(updates[0]).toMatchObject({ newVersion: '^9.1.0', updateType: 'minor' }) | |
| 171 | }) | |
| 172 | ||
| 173 | it('edge case - an unresolvable runtime yields nothing rather than an error', async () => { | |
| 174 | const updates = await check(buddyWithRegistry({ pnpm: null }), [manifestWith({ pnpm: '^9.0.0' })]) | |
| 175 | ||
| 176 | expect(updates).toEqual([]) | |
| 177 | }) | |
| 178 | ||
| 179 | it('edge case - a manifest with no engines makes no lookups', async () => { | |
| 180 | let looked = false | |
| 181 | const buddy = buddyWithRegistry({}) | |
| 182 | ;(buddy as never as { registryClient: { getLatestVersion: () => Promise<null> } }) | |
| 183 | .registryClient.getLatestVersion = async () => { | |
| 184 | looked = true | |
| 185 | return null | |
| 186 | } | |
| 187 | ||
| 188 | expect(await check(buddy, [manifestWith({})])).toEqual([]) | |
| 189 | expect(looked).toBe(false) | |
| 190 | }) | |
| 191 | }) | |
| 192 | ||
| 193 | describe('the opt-in', () => { | |
| 194 | it('success case - engines is a boolean in config', () => { | |
| 195 | expect(validateConfig({ packages: { strategy: 'all', engines: true } })).toEqual([]) | |
| 196 | }) | |
| 197 | ||
| 198 | it('failure case - a non-boolean is rejected', () => { | |
| 199 | const issues = validateConfig({ packages: { strategy: 'all', engines: 'yes' as never } }) | |
| 200 | ||
| 201 | expect(issues.some(issue => issue.path === 'packages.engines')).toBe(true) | |
| 202 | }) | |
| 203 | }) | |
| 204 | ||
| 205 | describe('writing engines through the file generator', () => { | |
| 206 | it('success case - an engines update reaches the manifest on disk', async () => { | |
| 207 | const dir = await fs.mkdtemp(join(tmpdir(), 'buddy-engines-')) | |
| 208 | const path = join(dir, 'package.json') | |
| 209 | await fs.writeFile(path, MANIFEST) | |
| 210 | ||
| 211 | try { | |
| 212 | const buddy = new Buddy({ packages: { strategy: 'all' } }, dir) | |
| 213 | const changes = await buddy.generateAllFileUpdates([{ ...engine('node', '>=20', '>=22'), file: path }]) | |
| 214 | ||
| 215 | // Before this, the update went through the dependency-section loop, | |
| 216 | // logged "Package node not found", and produced no change at all. | |
| 217 | expect(changes).toHaveLength(1) | |
| 218 | expect(changes[0].content).toContain('"node": ">=22"') | |
| 219 | } | |
| 220 | finally { | |
| 221 | await fs.rm(dir, { recursive: true, force: true }) | |
| 222 | } | |
| 223 | }) | |
| 224 | }) | |