-
Notifications
You must be signed in to change notification settings - Fork 246
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
extract dependency graph traversal to separate module & unit test it
- Loading branch information
1 parent
0f9222a
commit cea4368
Showing
3 changed files
with
227 additions
and
29 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
import * as fs from 'fs-extra'; | ||
import { join } from 'path'; | ||
|
||
import * as util from './util'; | ||
|
||
/** | ||
* Traverses the dependency graph and invokes the provided callback method for | ||
* each individual dependency root directory (including the current package). | ||
* The dependency roots are de-duplicated based on their absolute path on the | ||
* file system. | ||
* | ||
* @param packageDir the current package's root directory (i.e: where the | ||
* `package.json` file is located) | ||
* @param callback the function to invoke with each package's informations | ||
* @param fs the file system methods to use (this parameter should not | ||
* be provided unless this module is being unit tested) | ||
*/ | ||
export async function traverseDependencyGraph( | ||
packageDir: string, | ||
callback: Callback, | ||
host: TraverseDependencyGraphHost = { | ||
readJson: fs.readJson, | ||
resolveDependencyDirectory: util.resolveDependencyDirectory, | ||
}, | ||
): Promise<void> { | ||
return real$traverseDependencyGraph(packageDir, callback, host, new Set()); | ||
} | ||
|
||
/** | ||
* A callback invoked for each node in a NPM module's dependency graph. | ||
* | ||
* @param packageDir the directory where the current package is located. | ||
* @param meta the contents of the `package.json` file for this package. | ||
* @param root whether this package is the root that was provided to the | ||
* `traverseDependencyGraph` call. | ||
* | ||
* @returns `true` if this package's own dependencies should be processed, | ||
* `false` otherwise. | ||
*/ | ||
export type Callback = ( | ||
packageDir: string, | ||
meta: PackageJson, | ||
root: boolean, | ||
) => boolean | Promise<boolean>; | ||
|
||
/** | ||
* Host methods for traversing dependency graphs. | ||
*/ | ||
export interface TraverseDependencyGraphHost { | ||
readonly readJson: typeof fs.readJson; | ||
readonly resolveDependencyDirectory: typeof util.resolveDependencyDirectory; | ||
} | ||
|
||
/** | ||
* Contents of the `package.json` file. | ||
*/ | ||
export interface PackageJson { | ||
readonly dependencies?: { readonly [name: string]: string }; | ||
readonly peerDependencies?: { readonly [name: string]: string }; | ||
|
||
readonly [key: string]: unknown; | ||
} | ||
|
||
async function real$traverseDependencyGraph( | ||
packageDir: string, | ||
callback: Callback, | ||
host: TraverseDependencyGraphHost, | ||
visited: Set<string>, | ||
): Promise<void> { | ||
// We're at the root if we have not visited anything yet. How convenient! | ||
const isRoot = visited.size === 0; | ||
if (visited.has(packageDir)) { | ||
return void 0; | ||
} | ||
visited.add(packageDir); | ||
|
||
const meta: PackageJson = await host.readJson( | ||
join(packageDir, 'package.json'), | ||
); | ||
if (!(await callback(packageDir, meta, isRoot))) { | ||
return void 0; | ||
} | ||
|
||
const deps = new Set([ | ||
...Object.keys(meta.dependencies ?? {}), | ||
...Object.keys(meta.peerDependencies ?? {}), | ||
]); | ||
return Promise.all( | ||
Array.from(deps).map((dep) => { | ||
const dependencyDir = host.resolveDependencyDirectory(packageDir, dep); | ||
return real$traverseDependencyGraph( | ||
dependencyDir, | ||
callback, | ||
host, | ||
visited, | ||
); | ||
}), | ||
).then(); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
import { tmpdir } from 'os'; | ||
import { join } from 'path'; | ||
import { Callback, traverseDependencyGraph } from '../lib/dependency-graph'; | ||
|
||
const mockHost = { | ||
readJson: jest.fn<Promise<any>, [string]>().mockName('fs.readJson'), | ||
resolveDependencyDirectory: jest | ||
.fn<string, [string, string]>() | ||
.mockName('resolveDependencyDirectory'), | ||
}; | ||
|
||
afterEach((done) => { | ||
jest.resetAllMocks(); | ||
done(); | ||
}); | ||
|
||
test('de-duplicates package root directories', async () => { | ||
// GIVEN the following package dependency graph: | ||
// A -> B -> C | ||
// A -> C | ||
const packages: Record<string, { root: string; meta: any }> = { | ||
A: { | ||
root: join(tmpdir(), 'A'), | ||
meta: { dependencies: { B: '*' }, peerDependencies: { C: '*' } }, | ||
}, | ||
B: { root: join(tmpdir(), 'B'), meta: { dependencies: { C: '*' } } }, | ||
C: { root: join(tmpdir(), 'C'), meta: {} }, | ||
}; | ||
|
||
const cb: Callback = jest | ||
.fn() | ||
.mockName('callback') | ||
.mockImplementation(() => true); | ||
|
||
mockHost.readJson.mockImplementation((file) => { | ||
const result = Object.values(packages).find( | ||
({ root }) => file === join(root, 'package.json'), | ||
)?.meta; | ||
return result != null | ||
? Promise.resolve(result) | ||
: Promise.reject(new Error(`Unexpected file access: ${file}`)); | ||
}); | ||
|
||
mockHost.resolveDependencyDirectory.mockImplementation((_dir, dep) => { | ||
const result = packages[dep]?.root; | ||
if (result == null) { | ||
throw new Error(`Unknown dependency: ${dep}`); | ||
} | ||
return result; | ||
}); | ||
|
||
// WHEN | ||
await expect( | ||
traverseDependencyGraph(packages.A.root, cb, mockHost), | ||
).resolves.not.toThrow(); | ||
|
||
// THEN | ||
expect(cb).toHaveBeenCalledTimes(3); | ||
|
||
for (const { root, meta } of Object.values(packages)) { | ||
expect(cb).toHaveBeenCalledWith(root, meta, root === packages.A.root); | ||
} | ||
|
||
expect(mockHost.readJson).toHaveBeenCalledTimes(3); | ||
expect(mockHost.resolveDependencyDirectory).toHaveBeenCalledTimes(3); | ||
}); | ||
|
||
test('stops traversing when callback returns false', async () => { | ||
// GIVEN the following package dependency graph: | ||
// A -> B -> C | ||
const packages: Record<string, { root: string; meta: any }> = { | ||
A: { root: join(tmpdir(), 'A'), meta: { dependencies: { B: '*' } } }, | ||
B: { root: join(tmpdir(), 'B'), meta: { peerDependencies: { C: '*' } } }, | ||
C: { root: join(tmpdir(), 'C'), meta: {} }, | ||
}; | ||
|
||
// The callback requests aborting once it reached B | ||
const cb: Callback = jest | ||
.fn() | ||
.mockName('callback') | ||
.mockImplementation((root) => root !== packages.B.root); | ||
|
||
mockHost.readJson.mockImplementation((file) => { | ||
const result = Object.values(packages).find( | ||
({ root }) => file === join(root, 'package.json'), | ||
)?.meta; | ||
return result != null | ||
? Promise.resolve(result) | ||
: Promise.reject(new Error(`Unexpected file access: ${file}`)); | ||
}); | ||
|
||
mockHost.resolveDependencyDirectory.mockImplementation((_dir, dep) => { | ||
const result = packages[dep]?.root; | ||
if (result == null) { | ||
throw new Error(`Unknown dependency: ${dep}`); | ||
} | ||
return result; | ||
}); | ||
|
||
// WHEN | ||
await expect( | ||
traverseDependencyGraph(packages.A.root, cb, mockHost), | ||
).resolves.not.toThrow(); | ||
|
||
// THEN | ||
expect(cb).toHaveBeenCalledTimes(2); | ||
|
||
expect(cb).toHaveBeenCalledWith(packages.A.root, packages.A.meta, true); | ||
expect(cb).toHaveBeenCalledWith(packages.B.root, packages.B.meta, false); | ||
|
||
expect(mockHost.readJson).toHaveBeenCalledTimes(2); | ||
expect(mockHost.resolveDependencyDirectory).toHaveBeenCalledTimes(1); | ||
}); |