Skip to content
Merged
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
4 changes: 3 additions & 1 deletion depsynky/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bpinternal/depsynky",
"version": "0.3.0",
"version": "0.3.1",
"description": "CLI to synchronize dependencies across a pnpm mono-repo",
"main": "dist/index.js",
"repository": {
Expand Down Expand Up @@ -29,10 +29,12 @@
},
"devDependencies": {
"@types/node": "^22.16.4",
"@types/picomatch": "^4.0.2",
"@types/prettier": "^2.7.3",
"@types/prompts": "^2.0.14",
"@types/semver": "^7.3.11",
"esbuild": "^0.25.0",
"picomatch": "^4.0.4",
"ts-node": "^10.9.1",
"typescript": "^4.9.4",
"vitest": "^3.0.7"
Expand Down
15 changes: 15 additions & 0 deletions depsynky/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

163 changes: 163 additions & 0 deletions depsynky/src/__tests__/bump.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { test, expect, describe } from 'vitest'
import { buildApp } from './utils/test-setup'
import { DepSynkyError } from '../errors'

describe('bumpVersion', () => {
test('bumps a package version with patch', async () => {
const { app, pkg } = buildApp(
{
packages: [
{ name: 'pkg-a', version: '1.0.0' },
{ name: 'pkg-b', version: '1.0.0' }
]
},
async () => 'patch'
)

await app.bumpVersion({ pkgName: 'pkg-a', sync: false })

const pkgA = await pkg.read('pkg-a')
expect(pkgA.version).toBe('1.0.1')
})

test('bumps a package version with minor', async () => {
const { app, pkg } = buildApp(
{
packages: [{ name: 'pkg-a', version: '1.0.0' }]
},
async () => 'minor'
)

await app.bumpVersion({ pkgName: 'pkg-a', sync: false })

const pkgA = await pkg.read('pkg-a')
expect(pkgA.version).toBe('1.1.0')
})

test('bumps a package version with major', async () => {
const { app, pkg } = buildApp(
{
packages: [{ name: 'pkg-a', version: '1.0.0' }]
},
async () => 'major'
)

await app.bumpVersion({ pkgName: 'pkg-a', sync: false })

const pkgA = await pkg.read('pkg-a')
expect(pkgA.version).toBe('2.0.0')
})

test('skips bump when "none" is selected', async () => {
const { app, pkg } = buildApp(
{
packages: [{ name: 'pkg-a', version: '1.0.0' }]
},
async () => 'none'
)

await app.bumpVersion({ pkgName: 'pkg-a', sync: false })

const pkgA = await pkg.read('pkg-a')
expect(pkgA.version).toBe('1.0.0')
})

test('skips private packages during bump', async () => {
const { app, pkg } = buildApp(
{
packages: [
{ name: 'pkg-a', version: '1.0.0' },
{ name: 'pkg-b', version: '1.0.0', private: true, dependencies: { 'pkg-a': '^1.0.0' } }
]
},
async () => 'patch' // only one prompt because pkg-b is private and skipped
)

await app.bumpVersion({ pkgName: 'pkg-a', sync: false })

const pkgA = await pkg.read('pkg-a')
expect(pkgA.version).toBe('1.0.1')

const pkgB = await pkg.read('pkg-b')
expect(pkgB.version).toBe('1.0.0') // not bumped
})

test('bumps dependents recursively', async () => {
const { app, pkg } = buildApp(
{
packages: [
{ name: 'pkg-a', version: '1.0.0' },
{ name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } },
{ name: 'pkg-c', version: '1.0.0', dependencies: { 'pkg-b': '^1.0.0' } }
]
},
async ({ pkgName }) => {
if (pkgName === 'pkg-a') return 'patch'
if (pkgName === 'pkg-b') return 'minor'
if (pkgName === 'pkg-c') return 'patch'
return 'none'
}
)

await app.bumpVersion({ pkgName: 'pkg-a', sync: false })

const pkgA = await pkg.read('pkg-a')
expect(pkgA.version).toBe('1.0.1')

const pkgB = await pkg.read('pkg-b')
expect(pkgB.version).toBe('1.1.0')

const pkgC = await pkg.read('pkg-c')
expect(pkgC.version).toBe('1.0.1')
})

test('calls syncVersions when sync is true', async () => {
const { app, pkg } = buildApp(
{
packages: [
{ name: 'pkg-a', version: '1.0.0' },
{ name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } }
]
},
async ({ pkgName }) => (pkgName === 'pkg-a' ? 'patch' : 'none')
)

await app.bumpVersion({ pkgName: 'pkg-a', sync: true })

const pkgA = await pkg.read('pkg-a')
expect(pkgA.version).toBe('1.0.1')

// NOTE: syncVersions is called but not awaited inside bumpVersion,
// so we allow a microtask tick for the fire-and-forget sync to complete
await new Promise((r) => setTimeout(r, 10))

const pkgB = await pkg.read('pkg-b')
expect(pkgB.dependencies?.['pkg-a']).toBe('^1.0.1')
})

test('throws when package is not found', async () => {
const { app } = buildApp({
packages: [{ name: 'pkg-a', version: '1.0.0' }]
})

await expect(app.bumpVersion({ pkgName: 'nonexistent', sync: false })).rejects.toThrow(DepSynkyError)
})

test('does not sync when sync is false', async () => {
const { app, pkg } = buildApp(
{
packages: [
{ name: 'pkg-a', version: '1.0.0' },
{ name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } }
]
},
async ({ pkgName }) => (pkgName === 'pkg-a' ? 'major' : 'none')
)

await app.bumpVersion({ pkgName: 'pkg-a', sync: false })

const pkgB = await pkg.read('pkg-b')
// dependency should NOT be updated because sync is false
expect(pkgB.dependencies?.['pkg-a']).toBe('^1.0.0')
})
})
147 changes: 147 additions & 0 deletions depsynky/src/__tests__/check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { test, expect, describe } from 'vitest'
import { buildApp } from './utils/test-setup'
import { DepSynkyError } from '../errors'

describe('checkVersions', () => {
test('passes when all dependencies are in sync', async () => {
const { app } = buildApp({
packages: [
{ name: 'pkg-a', version: '1.0.0' },
{ name: 'pkg-b', version: '2.0.0', dependencies: { 'pkg-a': '^1.0.0' } }
]
})

await expect(app.checkVersions({})).resolves.not.toThrow()
})

test('throws when a dependency is out of sync', async () => {
const { app } = buildApp({
packages: [
{ name: 'pkg-a', version: '2.0.0' },
{ name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } }
]
})

await expect(app.checkVersions({})).rejects.toThrow(DepSynkyError)
await expect(app.checkVersions({})).rejects.toThrow('out of sync')
})

test('throws when a devDependency is out of sync', async () => {
const { app } = buildApp({
packages: [
{ name: 'pkg-a', version: '2.0.0' },
{ name: 'pkg-b', version: '1.0.0', devDependencies: { 'pkg-a': '^1.0.0' } }
]
})

await expect(app.checkVersions({})).rejects.toThrow(DepSynkyError)
})

test('throws when a peerDependency is out of sync', async () => {
const { app } = buildApp({
packages: [
{ name: 'pkg-a', version: '2.0.0' },
{ name: 'pkg-b', version: '1.0.0', peerDependencies: { 'pkg-a': '^1.0.0' } }
]
})

await expect(app.checkVersions({})).rejects.toThrow(DepSynkyError)
})

test('ignores out-of-sync peerDependencies when ignorePeers is true', async () => {
const { app } = buildApp({
packages: [
{ name: 'pkg-a', version: '2.0.0' },
{ name: 'pkg-b', version: '1.0.0', peerDependencies: { 'pkg-a': '^1.0.0' } }
]
})

await expect(app.checkVersions({ ignorePeers: true })).resolves.not.toThrow()
})

test('ignores out-of-sync devDependencies when ignoreDev is true', async () => {
const { app } = buildApp({
packages: [
{ name: 'pkg-a', version: '2.0.0' },
{ name: 'pkg-b', version: '1.0.0', devDependencies: { 'pkg-a': '^1.0.0' } }
]
})

await expect(app.checkVersions({ ignoreDev: true })).resolves.not.toThrow()
})

test('still checks dependencies when ignorePeers and ignoreDev are true', async () => {
const { app } = buildApp({
packages: [
{ name: 'pkg-a', version: '2.0.0' },
{ name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } }
]
})

await expect(app.checkVersions({ ignorePeers: true, ignoreDev: true })).rejects.toThrow(DepSynkyError)
})

test('skips local (workspace:) versions for private packages', async () => {
const { app } = buildApp({
packages: [
{ name: 'pkg-a', version: '1.0.0' },
{ name: 'pkg-b', version: '1.0.0', private: true, dependencies: { 'pkg-a': 'workspace:*' } }
]
})

await expect(app.checkVersions({})).resolves.not.toThrow()
})

test('throws for local (workspace:) versions on public packages', async () => {
const { app } = buildApp({
packages: [
{ name: 'pkg-a', version: '1.0.0' },
{ name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': 'workspace:*' } }
]
})

await expect(app.checkVersions({})).rejects.toThrow(DepSynkyError)
await expect(app.checkVersions({})).rejects.toThrow('public and cannot depend on local package')
})

test('passes with custom targetVersions', async () => {
const { app } = buildApp({
packages: [
{ name: 'pkg-a', version: '1.0.0' },
{ name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } }
]
})

await expect(app.checkVersions({ targetVersions: { 'pkg-a': '1.0.0' } })).resolves.not.toThrow()
})

test('fails with custom targetVersions that are not satisfied', async () => {
const { app } = buildApp({
packages: [
{ name: 'pkg-a', version: '2.0.0' },
{ name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } }
]
})

await expect(app.checkVersions({ targetVersions: { 'pkg-a': '2.0.0' } })).rejects.toThrow(DepSynkyError)
})

test('passes when packages have no dependencies', async () => {
const { app } = buildApp({
packages: [
{ name: 'pkg-a', version: '1.0.0' },
{ name: 'pkg-b', version: '2.0.0' }
]
})

await expect(app.checkVersions({})).resolves.not.toThrow()
})

test('ignores dependencies on packages not in the monorepo', async () => {
const { app } = buildApp({
packages: [{ name: 'pkg-a', version: '1.0.0', dependencies: { lodash: '^4.0.0' } }]
})

await expect(app.checkVersions({})).resolves.not.toThrow()
})
})
Loading
Loading