Skip to content

Commit 2aedf94

Browse files
authored
fix: search for lockfile in parent directories (#1139)
1 parent f718bc1 commit 2aedf94

File tree

4 files changed

+171
-83
lines changed

4 files changed

+171
-83
lines changed

packages/cli/src/services/check-parser/package-files/package-manager.ts

Lines changed: 108 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import fs from 'node:fs/promises'
22
import path from 'node:path'
33

4+
import { lineage } from './walk'
5+
46
export class Runnable {
57
executable: string
68
args: string[]
@@ -44,7 +46,8 @@ export abstract class PackageManagerDetector {
4446
abstract get name (): string
4547
abstract detectUserAgent (userAgent: string): boolean
4648
abstract detectRuntime (): boolean
47-
abstract detectLockfile (dir: string): Promise<void>
49+
abstract get representativeLockfile (): string | undefined
50+
abstract detectLockfile (dir: string): Promise<string>
4851
abstract detectExecutable (lookup: PathLookup): Promise<void>
4952
abstract installCommand (): Runnable
5053
abstract execCommand (args: string[]): Runnable
@@ -63,8 +66,12 @@ export class NpmDetector extends PackageManagerDetector implements PackageManage
6366
return false
6467
}
6568

66-
async detectLockfile (dir: string): Promise<void> {
67-
return await accessR(path.join(dir, 'package-lock.json'))
69+
get representativeLockfile (): string {
70+
return 'package-lock.json'
71+
}
72+
73+
async detectLockfile (dir: string): Promise<string> {
74+
return await accessR(path.join(dir, this.representativeLockfile))
6875
}
6976

7077
async detectExecutable (lookup: PathLookup): Promise<void> {
@@ -93,8 +100,12 @@ export class CNpmDetector extends PackageManagerDetector implements PackageManag
93100
return false
94101
}
95102

103+
get representativeLockfile (): undefined {
104+
return
105+
}
106+
96107
// eslint-disable-next-line require-await
97-
async detectLockfile (): Promise<void> {
108+
async detectLockfile (): Promise<string> {
98109
throw new NotDetectedError()
99110
}
100111

@@ -124,8 +135,12 @@ export class PNpmDetector extends PackageManagerDetector implements PackageManag
124135
return false
125136
}
126137

127-
async detectLockfile (dir: string): Promise<void> {
128-
return await accessR(path.join(dir, 'pnpm-lock.yaml'))
138+
get representativeLockfile (): string {
139+
return 'pnpm-lock.yaml'
140+
}
141+
142+
async detectLockfile (dir: string): Promise<string> {
143+
return await accessR(path.join(dir, this.representativeLockfile))
129144
}
130145

131146
async detectExecutable (lookup: PathLookup): Promise<void> {
@@ -154,8 +169,12 @@ export class YarnDetector extends PackageManagerDetector implements PackageManag
154169
return false
155170
}
156171

157-
async detectLockfile (dir: string): Promise<void> {
158-
return await accessR(path.join(dir, 'yarn.lock'))
172+
get representativeLockfile (): string {
173+
return 'yarn.lock'
174+
}
175+
176+
async detectLockfile (dir: string): Promise<string> {
177+
return await accessR(path.join(dir, this.representativeLockfile))
159178
}
160179

161180
async detectExecutable (lookup: PathLookup): Promise<void> {
@@ -184,8 +203,12 @@ export class DenoDetector extends PackageManagerDetector implements PackageManag
184203
return process.versions.deno !== undefined
185204
}
186205

187-
async detectLockfile (dir: string): Promise<void> {
188-
return await accessR(path.join(dir, 'deno.lock'))
206+
get representativeLockfile (): string {
207+
return 'deno.lock'
208+
}
209+
210+
async detectLockfile (dir: string): Promise<string> {
211+
return await accessR(path.join(dir, this.representativeLockfile))
189212
}
190213

191214
async detectExecutable (lookup: PathLookup): Promise<void> {
@@ -214,8 +237,12 @@ export class BunDetector extends PackageManagerDetector implements PackageManage
214237
return process.versions.bun !== undefined
215238
}
216239

217-
async detectLockfile (dir: string): Promise<void> {
218-
return await accessR(path.join(dir, 'bun.lockb'))
240+
get representativeLockfile (): string {
241+
return 'bun.lockb'
242+
}
243+
244+
async detectLockfile (dir: string): Promise<string> {
245+
return await accessR(path.join(dir, this.representativeLockfile))
219246
}
220247

221248
async detectExecutable (lookup: PathLookup): Promise<void> {
@@ -231,17 +258,19 @@ export class BunDetector extends PackageManagerDetector implements PackageManage
231258
}
232259
}
233260

234-
async function accessR (filePath: string): Promise<void> {
261+
async function accessR (filePath: string): Promise<string> {
235262
try {
236263
await fs.access(filePath, fs.constants.R_OK)
264+
return filePath
237265
} catch {
238266
throw new NotDetectedError()
239267
}
240268
}
241269

242-
async function accessX (filePath: string): Promise<void> {
270+
async function accessX (filePath: string): Promise<string> {
243271
try {
244272
await fs.access(filePath, fs.constants.X_OK)
273+
return filePath
245274
} catch {
246275
throw new NotDetectedError()
247276
}
@@ -333,13 +362,13 @@ export const knownPackageManagers: PackageManagerDetector[] = [
333362
npmDetector,
334363
]
335364

336-
export interface DetectPackageManagerOptions {
365+
export interface DetectOptions {
337366
detectors?: PackageManagerDetector[]
338367
}
339368

340369
export async function detectPackageManager (
341370
dir: string,
342-
options?: DetectPackageManagerOptions,
371+
options?: DetectOptions,
343372
): Promise<PackageManager> {
344373
const detectors = options?.detectors ?? knownPackageManagers
345374

@@ -362,10 +391,11 @@ export async function detectPackageManager (
362391

363392
// Next, try to find a lockfile.
364393
try {
365-
return await Promise.any(detectors.map(async detector => {
366-
await detector.detectLockfile(dir)
367-
return detector
368-
}))
394+
const { packageManager } = await detectNearestLockfile(dir, {
395+
detectors,
396+
})
397+
398+
return packageManager
369399
} catch {
370400
// Nothing detected.
371401
}
@@ -387,3 +417,61 @@ export async function detectPackageManager (
387417
// If all else fails, just assume npm.
388418
return npmDetector
389419
}
420+
421+
export interface NearestLockFile {
422+
packageManager: PackageManager
423+
lockfile: string
424+
}
425+
426+
export class NoLockfileFoundError extends Error {
427+
searchPaths: string[]
428+
lockfiles: string[]
429+
430+
constructor (searchPaths: string[], lockfiles: string[], options?: ErrorOptions) {
431+
const message = `Unable to detect a lockfile in any of the following paths:`
432+
+ `\n\n`
433+
+ `${searchPaths.map(searchPath => ` ${searchPath}`).join('\n')}`
434+
+ `\n\n`
435+
+ `Lockfiles we looked for:`
436+
+ `\n\n`
437+
+ `${lockfiles.map(lockfile => ` ${lockfile}`).join('\n')}`
438+
super(message, options)
439+
this.name = 'NoLockfileFoundError'
440+
this.searchPaths = searchPaths
441+
this.lockfiles = lockfiles
442+
}
443+
}
444+
445+
export async function detectNearestLockfile (
446+
dir: string,
447+
options?: DetectOptions,
448+
): Promise<NearestLockFile> {
449+
const detectors = options?.detectors ?? knownPackageManagers
450+
451+
const searchPaths: string[] = []
452+
453+
// Next, try to find a lockfile.
454+
for (const searchPath of lineage(dir)) {
455+
try {
456+
searchPaths.push(searchPath)
457+
458+
// Assume that only a single kind of lockfile exists, which means the
459+
// resolve order does not matter.
460+
return await Promise.any(detectors.map(async detector => {
461+
const lockfile = await detector.detectLockfile(searchPath)
462+
return {
463+
packageManager: detector,
464+
lockfile,
465+
}
466+
}))
467+
} catch {
468+
// Nothing detected.
469+
}
470+
}
471+
472+
const lockfiles = detectors.reduce<string[]>((acc, detector) => {
473+
return acc.concat(detector.representativeLockfile ?? [])
474+
}, [])
475+
476+
throw new NoLockfileFoundError(searchPaths, lockfiles)
477+
}

packages/cli/src/services/check-parser/package-files/resolver.ts

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { isBuiltinPath, isLocalPath, PathResult } from './paths'
88
import { FileLoader, LoadFile } from './loader'
99
import { JsonSourceFile } from './json-source-file'
1010
import { LookupContext } from './lookup'
11+
import { walkUp, WalkUpOptions } from './walk'
1112

1213
class PackageFilesCache {
1314
#sourceFileCache = new FileLoader(SourceFile.loadFromFilePath)
@@ -142,48 +143,6 @@ export type Dependencies = {
142143
local: LocalDependency[]
143144
}
144145

145-
export interface WalkUpOptions {
146-
root?: string
147-
isDir?: boolean
148-
}
149-
150-
async function walkUp (
151-
filePath: string,
152-
find: (dirPath: string) => Promise<boolean>,
153-
options?: WalkUpOptions,
154-
): Promise<boolean> {
155-
let currentPath = filePath
156-
157-
if (options?.isDir === true) {
158-
// To keep things simple, just add a dummy component.
159-
currentPath = path.join(currentPath, 'z')
160-
}
161-
162-
while (true) {
163-
const prevPath = currentPath
164-
165-
currentPath = path.dirname(prevPath)
166-
167-
// Bail out if we reach root.
168-
if (prevPath === currentPath) {
169-
break
170-
}
171-
172-
const found = await find(currentPath)
173-
if (found) {
174-
return true
175-
}
176-
177-
// Stop if we reach the user-specified root directory.
178-
// TODO: I don't like a string comparison for this but it'll do for now.
179-
if (currentPath === options?.root) {
180-
break
181-
}
182-
}
183-
184-
return false
185-
}
186-
187146
export class PackageFilesResolver {
188147
cache = new PackageFilesCache()
189148

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { resolve, dirname } from 'node:path'
2+
3+
export interface WalkUpOptions extends LineageOptions {
4+
isDir?: boolean
5+
}
6+
7+
export async function walkUp (
8+
filePath: string,
9+
find: (dirPath: string) => Promise<boolean>,
10+
options?: WalkUpOptions,
11+
): Promise<boolean> {
12+
let startPath = filePath
13+
14+
if (options?.isDir !== true) {
15+
startPath = dirname(startPath)
16+
}
17+
18+
for (const dirPath of lineage(startPath, options)) {
19+
const found = await find(dirPath)
20+
if (found) {
21+
return true
22+
}
23+
}
24+
25+
return false
26+
}
27+
28+
export interface LineageOptions {
29+
root?: string
30+
}
31+
32+
export function* lineage (path: string, options?: LineageOptions): Generator<string, void> {
33+
let currentPath = resolve(path)
34+
35+
const stopRoot = options?.root && resolve(options.root)
36+
37+
// Lineage includes the starting path itself.
38+
yield currentPath
39+
40+
while (true) {
41+
const prevPath = currentPath
42+
43+
// Stop if we reach the user-specified root directory.
44+
// TODO: I don't like a string comparison for this but it'll do for now.
45+
if (prevPath === stopRoot) {
46+
return
47+
}
48+
49+
currentPath = dirname(prevPath)
50+
51+
// Bail out if we reach root.
52+
if (prevPath === currentPath) {
53+
return
54+
}
55+
56+
yield currentPath
57+
}
58+
}

packages/cli/src/services/util.ts

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ import { ChecklyConfig, PlaywrightSlimmedProp } from './checkly-config-loader'
1616
import { Parser } from './check-parser/parser'
1717
import * as JSON5 from 'json5'
1818
import { PlaywrightConfig } from './playwright-config'
19-
import { access, readFile } from 'fs/promises'
19+
import { readFile } from 'fs/promises'
2020
import { createHash } from 'crypto'
2121
import { Session } from '../constructs'
2222
import semver from 'semver'
23+
import { detectNearestLockfile } from './check-parser/package-files/package-manager'
2324

2425
export interface GitInformation {
2526
commitId: string
@@ -208,13 +209,10 @@ export async function bundlePlayWrightProject (
208209
archive.pipe(output)
209210

210211
const pwConfigParsed = new PlaywrightConfig(filePath, pwtConfig)
211-
const lockFile = await findLockFile(dir)
212-
if (!lockFile) {
213-
throw new Error('No lock file found')
214-
}
212+
const { lockfile } = await detectNearestLockfile(dir)
215213

216214
const [cacheHash, playwrightVersion] = await Promise.all([
217-
getCacheHash(lockFile),
215+
getCacheHash(lockfile),
218216
getPlaywrightVersion(dir),
219217
loadPlaywrightProjectFiles(dir, pwConfigParsed, include, archive),
220218
])
@@ -263,21 +261,6 @@ export async function getPlaywrightVersion (projectDir: string): Promise<string
263261
}
264262
}
265263

266-
async function findLockFile (dir: string): Promise<string | null> {
267-
const lockFiles = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']
268-
269-
for (const lockFile of lockFiles) {
270-
const filePath = path.join(dir, lockFile)
271-
try {
272-
await access(filePath)
273-
return filePath
274-
} catch {
275-
// Ignore errors, just check the next file
276-
}
277-
}
278-
return null // Return null if no lock file is found
279-
}
280-
281264
export async function loadPlaywrightProjectFiles (
282265
dir: string, pwConfigParsed: PlaywrightConfig, include: string[], archive: Archiver,
283266
) {

0 commit comments

Comments
 (0)