ReviewOS

also looking at this

stacks/buddy

fix(test): defuse the suite's landmines, and cover the dossier's untested modules

#1436
Merged glennmichael123 wants to merge fix/test-landmines into main
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.

test/composer-constraint-updates.test.tsmodified+89-0
Changes to test/composer-constraint-updates.test.ts
@@ -0,0 +1,89 @@
1import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'
2import * as fs from 'node:fs'
3import { 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 */
12describe('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})
test/dependency-file-parser.test.tsmodified+18-2
Changes to test/dependency-file-parser.test.ts
@@ -16,13 +16,25 @@ mock.module('ts-pantry', () => ({
1616
1717const mockReadFileSync = mock()
1818const 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 */
28let fsMocksArmed = false
1929mock.module('node:fs', () => {
2030 // eslint-disable-next-line ts/no-require-imports
2131 const originalFs = require('node:fs')
2232 return {
2333 ...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),
2638 }
2739})
2840
@@ -36,9 +48,13 @@ describe('Dependency File Parser', () => {
3648 // Set up default mock behaviors
3749 mockExistsSync.mockReturnValue(true)
3850 mockReadFileSync.mockReturnValue('mock content')
51 fsMocksArmed = true
3952 })
4053
4154 afterEach(() => {
55 // Hand the real fs back to everyone else before anything runs after us.
56 fsMocksArmed = false
57
4258 // Complete cleanup
4359 mockResolveDependencyFile.mockReset()
4460 mockReadFileSync.mockReset()
test/preload.tsmodified+19-0
Changes to test/preload.ts
@@ -16,6 +16,18 @@
1616 */
1717import { afterEach, beforeEach } from 'bun:test'
1818
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 */
29const repoRoot = process.cwd()
30
1931/** Set by {@link allowNetwork} for the rare test that genuinely needs the wire. */
2032let networkAllowed = false
2133
@@ -58,6 +70,13 @@ beforeEach(() => {
5870 networkAllowed = false
5971 if (globalThis.fetch !== guardedFetch)
6072 globalThis.fetch = guardedFetch as unknown as typeof fetch
73
74 try {
75 process.cwd()
76 }
77 catch {
78 process.chdir(repoRoot)
79 }
6180})
6281
6382afterEach(() => {