Skip to content

Commit 73e001b

Browse files
fix: Outdated package detection (#528)
Co-authored-by: me <me@kentcdodds.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 1202dd0 commit 73e001b

2 files changed

Lines changed: 200 additions & 92 deletions

File tree

packages/workshop-utils/src/package-install/package-install-check.server.test.ts

Lines changed: 119 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,24 @@ import fs from 'node:fs/promises'
22
import os from 'node:os'
33
import path from 'node:path'
44
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
5-
import {
5+
6+
vi.mock('execa', () => ({
7+
execa: vi.fn(),
8+
}))
9+
10+
const { execa } = await import('execa')
11+
const {
612
getInstallCommand,
713
getRootPackageInstallStatus,
814
getRootPackageJsonPaths,
915
getWorkspaceInstallStatus,
10-
} from './package-install-check.server.ts'
16+
} = await import('./package-install-check.server.ts')
1117

1218
let tempDir: string
1319

1420
beforeEach(async () => {
1521
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'package-install-test-'))
22+
vi.mocked(execa).mockReset()
1623
})
1724

1825
afterEach(async () => {
@@ -36,23 +43,20 @@ async function writePackageJson(
3643
)
3744
}
3845

39-
async function createNodeModules(
40-
dir: string,
41-
packages: Array<string>,
42-
): Promise<void> {
43-
const nodeModulesPath = path.join(dir, 'node_modules')
44-
await fs.mkdir(nodeModulesPath, { recursive: true })
45-
46-
for (const pkg of packages) {
47-
if (pkg.startsWith('@')) {
48-
const [scope, name] = pkg.split('/')
49-
const scopePath = path.join(nodeModulesPath, scope!)
50-
await fs.mkdir(scopePath, { recursive: true })
51-
await fs.mkdir(path.join(scopePath, name!), { recursive: true })
52-
} else {
53-
await fs.mkdir(path.join(nodeModulesPath, pkg), { recursive: true })
54-
}
55-
}
46+
function mockNpmLsResult({
47+
exitCode,
48+
dependencies = {},
49+
stdout,
50+
}: {
51+
exitCode: number
52+
dependencies?: Record<string, { missing?: boolean; invalid?: boolean }>
53+
stdout?: string
54+
}) {
55+
vi.mocked(execa).mockResolvedValue({
56+
exitCode,
57+
stdout: stdout ?? JSON.stringify({ dependencies }),
58+
stderr: '',
59+
} as never)
5660
}
5761

5862
describe('getInstallCommand', () => {
@@ -94,7 +98,10 @@ describe('getRootPackageInstallStatus', () => {
9498
packageManager: 'npm@10.0.0',
9599
dependencies: { react: '^18.0.0' },
96100
})
97-
await createNodeModules(tempDir, ['react'])
101+
mockNpmLsResult({
102+
exitCode: 0,
103+
dependencies: { react: {} },
104+
})
98105

99106
const status = await getRootPackageInstallStatus(
100107
path.join(tempDir, 'package.json'),
@@ -111,7 +118,10 @@ describe('getRootPackageInstallStatus', () => {
111118
packageManager: 'pnpm@8.0.0',
112119
dependencies: { react: '^18.0.0' },
113120
})
114-
await createNodeModules(tempDir, ['react'])
121+
mockNpmLsResult({
122+
exitCode: 0,
123+
dependencies: { react: {} },
124+
})
115125

116126
const status = await getRootPackageInstallStatus(
117127
path.join(tempDir, 'package.json'),
@@ -128,7 +138,10 @@ describe('getRootPackageInstallStatus', () => {
128138
packageManager: 'yarn@4.0.0',
129139
dependencies: { react: '^18.0.0' },
130140
})
131-
await createNodeModules(tempDir, ['react'])
141+
mockNpmLsResult({
142+
exitCode: 0,
143+
dependencies: { react: {} },
144+
})
132145

133146
const status = await getRootPackageInstallStatus(
134147
path.join(tempDir, 'package.json'),
@@ -145,7 +158,10 @@ describe('getRootPackageInstallStatus', () => {
145158
packageManager: 'bun@1.0.0',
146159
dependencies: { react: '^18.0.0' },
147160
})
148-
await createNodeModules(tempDir, ['react'])
161+
mockNpmLsResult({
162+
exitCode: 0,
163+
dependencies: { react: {} },
164+
})
149165

150166
const status = await getRootPackageInstallStatus(
151167
path.join(tempDir, 'package.json'),
@@ -161,7 +177,10 @@ describe('getRootPackageInstallStatus', () => {
161177
name: 'test-package',
162178
dependencies: { react: '^18.0.0' },
163179
})
164-
await createNodeModules(tempDir, ['react'])
180+
mockNpmLsResult({
181+
exitCode: 0,
182+
dependencies: { react: {} },
183+
})
165184

166185
const status = await getRootPackageInstallStatus(
167186
path.join(tempDir, 'package.json'),
@@ -176,13 +195,17 @@ describe('getRootPackageInstallStatus', () => {
176195
name: 'test-package',
177196
dependencies: { react: '^18.0.0' },
178197
})
198+
mockNpmLsResult({
199+
exitCode: 1,
200+
stdout: '',
201+
})
179202

180203
const status = await getRootPackageInstallStatus(
181204
path.join(tempDir, 'package.json'),
182205
)
183206

184207
expect(status.dependenciesNeedInstall).toBe(true)
185-
expect(status.reason).toBe('missing-node-modules')
208+
expect(status.reason).toBe('missing-dependencies')
186209
expect(status.missingDependencies).toEqual(['react'])
187210
})
188211

@@ -191,7 +214,13 @@ describe('getRootPackageInstallStatus', () => {
191214
name: 'test-package',
192215
dependencies: { react: '^18.0.0', 'react-dom': '^18.0.0' },
193216
})
194-
await createNodeModules(tempDir, ['react'])
217+
mockNpmLsResult({
218+
exitCode: 1,
219+
dependencies: {
220+
react: {},
221+
'react-dom': { missing: true },
222+
},
223+
})
195224

196225
const status = await getRootPackageInstallStatus(
197226
path.join(tempDir, 'package.json'),
@@ -200,6 +229,11 @@ describe('getRootPackageInstallStatus', () => {
200229
expect(status.dependenciesNeedInstall).toBe(true)
201230
expect(status.reason).toBe('missing-dependencies')
202231
expect(status.missingDependencies).toEqual(['react-dom'])
232+
expect(execa).toHaveBeenCalledWith(
233+
'npm',
234+
['ls', '--depth=0', '--json', 'react', 'react-dom'],
235+
expect.objectContaining({ cwd: tempDir, reject: false }),
236+
)
203237
})
204238

205239
test('detects missing devDependencies', async () => {
@@ -208,7 +242,14 @@ describe('getRootPackageInstallStatus', () => {
208242
dependencies: { react: '^18.0.0' },
209243
devDependencies: { typescript: '^5.0.0', vitest: '^1.0.0' },
210244
})
211-
await createNodeModules(tempDir, ['react', 'typescript'])
245+
mockNpmLsResult({
246+
exitCode: 1,
247+
dependencies: {
248+
react: {},
249+
typescript: {},
250+
vitest: { missing: true },
251+
},
252+
})
212253

213254
const status = await getRootPackageInstallStatus(
214255
path.join(tempDir, 'package.json'),
@@ -224,7 +265,10 @@ describe('getRootPackageInstallStatus', () => {
224265
name: 'test-package',
225266
dependencies: { '@epic-web/workshop-utils': '^1.0.0' },
226267
})
227-
await createNodeModules(tempDir, ['@epic-web/workshop-utils'])
268+
mockNpmLsResult({
269+
exitCode: 0,
270+
dependencies: { '@epic-web/workshop-utils': {} },
271+
})
228272

229273
const status = await getRootPackageInstallStatus(
230274
path.join(tempDir, 'package.json'),
@@ -239,7 +283,10 @@ describe('getRootPackageInstallStatus', () => {
239283
name: 'test-package',
240284
dependencies: { '@epic-web/workshop-utils': '^1.0.0' },
241285
})
242-
await createNodeModules(tempDir, [])
286+
mockNpmLsResult({
287+
exitCode: 1,
288+
dependencies: { '@epic-web/workshop-utils': { missing: true } },
289+
})
243290

244291
const status = await getRootPackageInstallStatus(
245292
path.join(tempDir, 'package.json'),
@@ -269,7 +316,13 @@ describe('getRootPackageInstallStatus', () => {
269316
dependencies: { react: '^18.0.0' },
270317
optionalDependencies: { 'optional-pkg': '^1.0.0' },
271318
})
272-
await createNodeModules(tempDir, ['react'])
319+
mockNpmLsResult({
320+
exitCode: 1,
321+
dependencies: {
322+
react: {},
323+
'optional-pkg': { missing: true },
324+
},
325+
})
273326

274327
const status = await getRootPackageInstallStatus(
275328
path.join(tempDir, 'package.json'),
@@ -301,7 +354,10 @@ describe('getRootPackageInstallStatus', () => {
301354
name: 'test-package',
302355
dependencies: { react: '^18.0.0', 'react-dom': '^18.0.0' },
303356
})
304-
await createNodeModules(tempDir, ['react', 'react-dom'])
357+
mockNpmLsResult({
358+
exitCode: 0,
359+
dependencies: { react: {}, 'react-dom': {} },
360+
})
305361

306362
const status1 = await getRootPackageInstallStatus(
307363
path.join(tempDir, 'package.json'),
@@ -316,7 +372,10 @@ describe('getRootPackageInstallStatus', () => {
316372
name: 'different-name',
317373
dependencies: { react: '^18.0.0', 'react-dom': '^18.0.0' },
318374
})
319-
await createNodeModules(tempDir2, ['react', 'react-dom'])
375+
mockNpmLsResult({
376+
exitCode: 0,
377+
dependencies: { react: {}, 'react-dom': {} },
378+
})
320379

321380
const status2 = await getRootPackageInstallStatus(
322381
path.join(tempDir2, 'package.json'),
@@ -411,7 +470,10 @@ describe('getWorkspaceInstallStatus', () => {
411470
packageManager: 'pnpm@8.0.0',
412471
dependencies: { react: '^18.0.0' },
413472
})
414-
await createNodeModules(tempDir, ['react'])
473+
mockNpmLsResult({
474+
exitCode: 0,
475+
dependencies: { react: {} },
476+
})
415477

416478
const status = await getWorkspaceInstallStatus(tempDir)
417479

@@ -426,15 +488,26 @@ describe('getWorkspaceInstallStatus', () => {
426488
name: 'root-package',
427489
dependencies: { react: '^18.0.0' },
428490
})
429-
await createNodeModules(tempDir, ['react'])
491+
vi.mocked(execa)
492+
.mockResolvedValueOnce({
493+
exitCode: 0,
494+
stdout: JSON.stringify({ dependencies: { react: {} } }),
495+
stderr: '',
496+
} as never)
497+
.mockResolvedValueOnce({
498+
exitCode: 1,
499+
stdout: JSON.stringify({
500+
dependencies: { typescript: { missing: true } },
501+
}),
502+
stderr: '',
503+
} as never)
430504

431505
const subDir = path.join(tempDir, 'sub')
432506
await fs.mkdir(subDir, { recursive: true })
433507
await writePackageJson(subDir, {
434508
name: 'sub-package',
435509
dependencies: { typescript: '^5.0.0' },
436510
})
437-
// Don't create node_modules for sub
438511

439512
const status = await getWorkspaceInstallStatus(tempDir)
440513

@@ -450,7 +523,17 @@ describe('getWorkspaceInstallStatus', () => {
450523
packageManager: 'yarn@4.0.0',
451524
dependencies: { react: '^18.0.0' },
452525
})
453-
await createNodeModules(tempDir, ['react'])
526+
vi.mocked(execa)
527+
.mockResolvedValueOnce({
528+
exitCode: 0,
529+
stdout: JSON.stringify({ dependencies: { react: {} } }),
530+
stderr: '',
531+
} as never)
532+
.mockResolvedValueOnce({
533+
exitCode: 0,
534+
stdout: JSON.stringify({ dependencies: { typescript: {} } }),
535+
stderr: '',
536+
} as never)
454537

455538
const subDir = path.join(tempDir, 'sub')
456539
await fs.mkdir(subDir, { recursive: true })
@@ -459,7 +542,6 @@ describe('getWorkspaceInstallStatus', () => {
459542
packageManager: 'bun@1.0.0',
460543
dependencies: { typescript: '^5.0.0' },
461544
})
462-
await createNodeModules(subDir, ['typescript'])
463545

464546
const status = await getWorkspaceInstallStatus(tempDir)
465547

0 commit comments

Comments
 (0)