also looking at this
fix(test): defuse the suite's landmines, and cover the dossier's untested modules
#1436
11 files
+617
-12
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.
| @@ -0,0 +1,89 @@ | ||
| 1 | import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test' | |
| 2 | import * as fs from 'node:fs' | |
| 3 | import { generateComposerUpdates } from '../src/utils/composer-parser' | |
| 4 | ||
| 5 | /** | |
| 6 | * This file sat in the tree as zero bytes — a suite that ran, passed and | |
| 7 | * asserted nothing, which reads as coverage in every listing. What its name | |
| 8 | * promises is the constraint-style contract: an update must keep the shape | |
| 9 | * the maintainer wrote (`^`, `~`, exact, compound range), because rewriting | |
| 10 | * `^10.0` to a bare `10.48.29` silently pins a project that chose a range. | |
| 11 | */ | |
| 12 | describe('composer constraint updates', () => { | |
| 13 | const composerJson = { | |
| 14 | 'require': { | |
| 15 | 'php': '^8.1', | |
| 16 | 'laravel/framework': '^10.0', | |
| 17 | 'symfony/console': '>=6.0,<7.0', | |
| 18 | 'monolog/monolog': '~3.0', | |
| 19 | 'guzzlehttp/guzzle': '7.8.1', | |
| 20 | }, | |
| 21 | 'require-dev': { | |
| 22 | 'phpunit/phpunit': '^10.0', | |
| 23 | }, | |
| 24 | } | |
| 25 | ||
| 26 | let readFileSpy: ReturnType<typeof spyOn> | |
| 27 | ||
| 28 | beforeEach(() => { | |
| 29 | readFileSpy = spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(composerJson, null, 2)) | |
| 30 | }) | |
| 31 | ||
| 32 | afterEach(() => { | |
| 33 | // Restore, or every later test that reads a real file gets composer.json. | |
| 34 | readFileSpy?.mockRestore?.() | |
| 35 | }) | |
| 36 | ||
| 37 | /** Run one update through the generator and hand back the rewritten JSON. */ | |
| 38 | async function updated(name: string, newVersion: string): Promise<Record<string, Record<string, string>>> { | |
| 39 | const result = await generateComposerUpdates([{ name, newVersion, file: 'composer.json' }]) | |
| 40 | expect(result).toHaveLength(1) | |
| 41 | return JSON.parse(result[0].content) | |
| 42 | } | |
| 43 | ||
| 44 | it('success case - a caret constraint keeps its caret', async () => { | |
| 45 | const json = await updated('laravel/framework', '10.48.29') | |
| 46 | ||
| 47 | expect(json.require['laravel/framework']).toBe('^10.48.29') | |
| 48 | }) | |
| 49 | ||
| 50 | it('success case - a tilde constraint keeps its tilde', async () => { | |
| 51 | const json = await updated('monolog/monolog', '3.9.0') | |
| 52 | ||
| 53 | expect(json.require['monolog/monolog']).toBe('~3.9.0') | |
| 54 | }) | |
| 55 | ||
| 56 | it('success case - an exact pin stays exact', async () => { | |
| 57 | const json = await updated('guzzlehttp/guzzle', '7.9.2') | |
| 58 | ||
| 59 | expect(json.require['guzzlehttp/guzzle']).toBe('7.9.2') | |
| 60 | }) | |
| 61 | ||
| 62 | it('success case - a compound range moves only its floor', async () => { | |
| 63 | // `>=6.0,<7.0` updated to 6.4.23 must not touch the ceiling: rewriting | |
| 64 | // both bounds would widen the range the maintainer deliberately closed. | |
| 65 | const json = await updated('symfony/console', '6.4.23') | |
| 66 | ||
| 67 | expect(json.require['symfony/console']).toBe('>=6.4.23,<7.0') | |
| 68 | }) | |
| 69 | ||
| 70 | it('success case - require-dev packages update with the same rules', async () => { | |
| 71 | const json = await updated('phpunit/phpunit', '10.5.20') | |
| 72 | ||
| 73 | expect(json['require-dev']['phpunit/phpunit']).toBe('^10.5.20') | |
| 74 | }) | |
| 75 | ||
| 76 | it('edge case - only the named package changes', async () => { | |
| 77 | const json = await updated('laravel/framework', '10.48.29') | |
| 78 | ||
| 79 | expect(json.require['symfony/console']).toBe('>=6.0,<7.0') | |
| 80 | expect(json.require['monolog/monolog']).toBe('~3.0') | |
| 81 | expect(json.require.php).toBe('^8.1') | |
| 82 | }) | |
| 83 | ||
| 84 | it('failure case - an unknown package leaves the file untouched', async () => { | |
| 85 | const json = await updated('vendor/not-here', '1.0.0') | |
| 86 | ||
| 87 | expect(json).toEqual(composerJson) | |
| 88 | }) | |
| 89 | }) | |
| @@ -16,13 +16,25 @@ mock.module('ts-pantry', () => ({ | ||
| 16 | 16 | |
| 17 | 17 | const mockReadFileSync = mock() |
| 18 | 18 | const mockExistsSync = mock() |
| 19 | ||
| 20 | /** | |
| 21 | * `mock.module` is process-global and permanent: whatever it installs is what | |
| 22 | * every later test file gets too. Handing it the bare mocks broke suites that | |
| 23 | * ran after this one — `existsSync` returned `undefined` everywhere once | |
| 24 | * `afterEach` reset the mocks (which is how #1434's decline test failed on CI | |
| 25 | * while passing alone). The replacement functions therefore delegate to the | |
| 26 | * real fs except while this suite has armed them. | |
| 27 | */ | |
| 28 | let fsMocksArmed = false | |
| 19 | 29 | mock.module('node:fs', () => { |
| 20 | 30 | // eslint-disable-next-line ts/no-require-imports |
| 21 | 31 | const originalFs = require('node:fs') |
| 22 | 32 | return { |
| 23 | 33 | ...originalFs, |
| 24 | readFileSync: mockReadFileSync, | |
| 25 | existsSync: mockExistsSync, | |
| 34 | readFileSync: (...args: unknown[]) => | |
| 35 | fsMocksArmed ? mockReadFileSync(...args) : originalFs.readFileSync(...args), | |
| 36 | existsSync: (...args: unknown[]) => | |
| 37 | fsMocksArmed ? mockExistsSync(...args) : originalFs.existsSync(...args), | |
| 26 | 38 | } |
| 27 | 39 | }) |
| 28 | 40 | |
| @@ -36,9 +48,13 @@ describe('Dependency File Parser', () => { | ||
| 36 | 48 | // Set up default mock behaviors |
| 37 | 49 | mockExistsSync.mockReturnValue(true) |
| 38 | 50 | mockReadFileSync.mockReturnValue('mock content') |
| 51 | fsMocksArmed = true | |
| 39 | 52 | }) |
| 40 | 53 | |
| 41 | 54 | afterEach(() => { |
| 55 | // Hand the real fs back to everyone else before anything runs after us. | |
| 56 | fsMocksArmed = false | |
| 57 | ||
| 42 | 58 | // Complete cleanup |
| 43 | 59 | mockResolveDependencyFile.mockReset() |
| 44 | 60 | mockReadFileSync.mockReset() |
| @@ -16,6 +16,18 @@ | ||
| 16 | 16 | */ |
| 17 | 17 | import { afterEach, beforeEach } from 'bun:test' |
| 18 | 18 | |
| 19 | /** | |
| 20 | * Repair a dead working directory between tests. | |
| 21 | * | |
| 22 | * A suite that `chdir`s into a temp directory and then deletes it can leave | |
| 23 | * `process.cwd()` pointing at nothing — every later test that touches the | |
| 24 | * filesystem, spawns a process or captures `cwd` in `beforeEach` then fails | |
| 25 | * with an unrelated-looking ENOENT. Suites defend individually; this is the | |
| 26 | * net under all of them. Repair-only: a *live* cwd is a suite's own business, | |
| 27 | * so this never moves one. | |
| 28 | */ | |
| 29 | const repoRoot = process.cwd() | |
| 30 | ||
| 19 | 31 | /** Set by {@link allowNetwork} for the rare test that genuinely needs the wire. */ |
| 20 | 32 | let networkAllowed = false |
| 21 | 33 | |
| @@ -58,6 +70,13 @@ beforeEach(() => { | ||
| 58 | 70 | networkAllowed = false |
| 59 | 71 | if (globalThis.fetch !== guardedFetch) |
| 60 | 72 | globalThis.fetch = guardedFetch as unknown as typeof fetch |
| 73 | ||
| 74 | try { | |
| 75 | process.cwd() | |
| 76 | } | |
| 77 | catch { | |
| 78 | process.chdir(repoRoot) | |
| 79 | } | |
| 61 | 80 | }) |
| 62 | 81 | |
| 63 | 82 | afterEach(() => { |