Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions tests/_notes/TESTING_FRAMEWORK_NOTE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Note: Tests were authored assuming Jest (describe/it/expect, jest.mock).
If the repository uses a different test runner (e.g., Mocha + Chai), adapt the syntax accordingly.
148 changes: 148 additions & 0 deletions tests/listChangedFiles.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Unit tests for listChangedFiles
* Testing Library/Framework: Jest (describe/it/expect, jest.mock).
* If the repository uses another framework, adapt as needed. We follow established Jest conventions.
*
* Focus: Scenarios derived from typical behavior of utilities listing changed files in a PR or local diff.
* - Happy paths: returns changed files, filters by extensions, ignores via globs, absolute/relative path handling.
* - Edge cases: empty output, whitespace-only output, duplicates and normalization.
* - Failure: underlying VCS command failure (e.g., git).
*/

const path = require('path')

// Attempt to import the subject from common locations.
let subject
try {
subject = subject || require('../src/listChangedFiles')
} catch (e1) {
try {
subject = subject || require('../lib/listChangedFiles')
} catch (e2) {
try {
subject = subject || require('../listChangedFiles')
} catch (e3) {
// Will handle missing module by skipping tests.
}
}
}

// Extract callable from default or named exports
const resolveCallable = (mod) => {
if (!mod) return null
if (typeof mod === 'function') return mod
if (mod.default && typeof mod.default === 'function') return mod.default
if (mod.listChangedFiles && typeof mod.listChangedFiles === 'function') return mod.listChangedFiles
return null
}

// Mocks
jest.mock('child_process', () => ({
execSync: jest.fn()
}))
jest.mock('fs', () => ({
existsSync: jest.fn(),
readFileSync: jest.fn(),
readdirSync: jest.fn()
}))

const { execSync } = require('child_process')
const fs = require('fs')

describe('listChangedFiles (Jest)', () => {
const callable = resolveCallable(subject)

beforeEach(() => {
jest.resetModules()
jest.clearAllMocks()
process.env = { ...process.env }
})

if (!callable) {
it('skips tests meaningfully when implementation cannot be resolved', () => {
expect(true).toBe(true)
})
return
}

it('returns an empty array when no changes are detected', async () => {
execSync.mockReturnValue(Buffer.from('', 'utf8'))
const result = await Promise.resolve(callable())
expect(result).toEqual([])
})

it('lists changed files from VCS output (happy path)', async () => {
execSync.mockReturnValue(Buffer.from('src/index.js\nsrc/utils/helpers.ts\nREADME.md\n', 'utf8'))
const result = await Promise.resolve(callable())
expect(result).toEqual(['src/index.js', 'src/utils/helpers.ts', 'README.md'])
})

it('removes duplicates and normalizes paths', async () => {
execSync.mockReturnValue(Buffer.from('./src/a.js\nsrc/a.js\nsrc/../src/a.js\nsrc/b.js\n', 'utf8'))
const result = await Promise.resolve(callable({ normalize: true }))
const sorted = Array.from(new Set(result)).sort()
expect(sorted).toEqual(['src/a.js', 'src/b.js'])
})

it('filters by allowed extensions', async () => {
execSync.mockReturnValue(Buffer.from('src/a.ts\nsrc/b.js\nsrc/c.md\nsrc/d.tsx\n', 'utf8'))
const result = await Promise.resolve(callable({ extensions: ['.ts', '.tsx'] }))
expect(result).toEqual(['src/a.ts', 'src/d.tsx'])
})

it('excludes files via ignore globs', async () => {
execSync.mockReturnValue(Buffer.from('docs/guide.md\nsrc/feature/new.ts\nREADME.md\nsrc/internal/_gen.js\n', 'utf8'))
const result = await Promise.resolve(callable({ ignore: ['docs/**', '*.md'] }))
expect(result).toContain('src/feature/new.ts')
expect(result).not.toContain('docs/guide.md')
expect(result).not.toContain('README.md')
})

it('returns absolute paths when configured', async () => {
execSync.mockReturnValue(Buffer.from('src/x.js\nsrc/y.js\n', 'utf8'))
const cwd = process.cwd()
const result = await Promise.resolve(callable({ absolute: true, cwd }))
expect(result.every(p => path.isAbsolute(p))).toBe(true)
expect(result).toContain(path.join(cwd, 'src/x.js'))
expect(result).toContain(path.join(cwd, 'src/y.js'))
})

it('filters out files that no longer exist when includeOnlyExisting is true', async () => {
execSync.mockReturnValue(Buffer.from('src/kept.js\nsrc/removed.js\n', 'utf8'))
fs.existsSync.mockImplementation((p) => path.basename(p) !== 'removed.js')
const result = await Promise.resolve(callable({ includeOnlyExisting: true }))
expect(result).toContain('src/kept.js')
expect(result).not.toContain('src/removed.js')
})

it('handles whitespace-only output', async () => {
execSync.mockReturnValue(Buffer.from(' \n \n', 'utf8'))
const result = await Promise.resolve(callable())
expect(result).toEqual([])
})

it('throws or rejects with informative error on underlying git failure', async () => {
const err = new Error('git failed with status 128')
execSync.mockImplementation(() => { throw err })
await expect(callable()).rejects.toThrow(/git/i)
})

it('supports custom base/target ref selection', async () => {
execSync.mockImplementation((cmd) => {
const s = String(cmd)
if (s.includes('main...feature') || s.includes('origin/main...HEAD')) {
return Buffer.from('src/changed.js\nsrc/other.ts\n', 'utf8')
}
return Buffer.from('', 'utf8')
})
const result = await Promise.resolve(callable({ baseRef: 'main', targetRef: 'feature' }))
expect(result).toEqual(['src/changed.js', 'src/other.ts'])
})

it('supports custom cwd for path resolution', async () => {
const fakeCwd = path.join(process.cwd(), 'packages', 'app')
execSync.mockReturnValue(Buffer.from('src/module/index.js\n', 'utf8'))
const result = await Promise.resolve(callable({ cwd: fakeCwd, absolute: true }))
expect(result[0]).toBe(path.join(fakeCwd, 'src/module/index.js'))
})
})