Skip to content

Commit 8e63d63

Browse files
committed
Fix monorepo support in project root detection
Fixes #312
1 parent 74a2952 commit 8e63d63

3 files changed

Lines changed: 234 additions & 12 deletions

File tree

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@
4848
"espree": "^11.1.0",
4949
"espurify": "^3.2.0",
5050
"micro-spelling-correcter": "^1.1.1",
51-
"package-directory": "^8.2.0",
5251
"resolve-from": "^5.0.0"
5352
},
5453
"devDependencies": {

test/util.js

Lines changed: 193 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import fs from 'node:fs/promises';
22
import path from 'node:path';
3+
import process from 'node:process';
34
import {createRequire} from 'node:module';
45
import test from 'ava';
56
import resolveFrom from 'resolve-from';
6-
import util from '../util.js';
7+
import util, {findProjectRoot} from '../util.js';
78

89
const require = createRequire(import.meta.url);
910
const packageJson = require('../package.json');
@@ -30,27 +31,211 @@ test.serial('loadAvaHelper retries lookup when helper becomes available', async
3031
});
3132

3233
const testFilename = path.join(fixtureRootDirectory, 'example.test.js');
33-
await fs.writeFile(path.join(fixtureRootDirectory, 'package.json'), '{"name":"fixture"}');
34+
await fs.writeFile(path.join(fixtureRootDirectory, 'package.json'), '{"name":"fixture","ava":{}}');
3435
await fs.writeFile(testFilename, '');
3536

3637
const overrides = {files: ['test.js']};
3738
const helperPath = path.join(fixtureRootDirectory, 'eslint-plugin-helper.js');
3839
await fs.writeFile(helperPath, 'module.exports = {load(rootDirectory, options) { return {rootDirectory, options}; }};');
3940

4041
const originalResolveFromSilent = resolveFrom.silent;
41-
let numberOfResolveCalls = 0;
42-
resolveFrom.silent = () => {
43-
numberOfResolveCalls += 1;
44-
return numberOfResolveCalls === 1 ? undefined : helperPath;
45-
};
42+
let isHelperAvailable = false;
43+
resolveFrom.silent = () => isHelperAvailable ? helperPath : undefined;
4644

4745
t.teardown(() => {
4846
resolveFrom.silent = originalResolveFromSilent;
4947
});
5048

5149
t.is(util.loadAvaHelper(testFilename, overrides), undefined);
50+
isHelperAvailable = true;
5251

5352
const helper = util.loadAvaHelper(testFilename, overrides);
54-
t.is(numberOfResolveCalls, 2);
5553
t.deepEqual(helper, {rootDirectory: fixtureRootDirectory, options: overrides});
5654
});
55+
56+
test.serial('loadAvaHelper resolves helper from sub-package when config is at workspace root', async t => {
57+
const workspaceRootDirectory = await fs.mkdtemp(path.join(import.meta.dirname, 'tmp-load-ava-helper-workspace-'));
58+
t.teardown(async () => {
59+
await fs.rm(workspaceRootDirectory, {recursive: true, force: true});
60+
});
61+
62+
await fs.writeFile(path.join(workspaceRootDirectory, 'package.json'), '{"name":"workspace","ava":{}}');
63+
const packageDirectory = path.join(workspaceRootDirectory, 'packages', 'pkg-a');
64+
await fs.mkdir(packageDirectory, {recursive: true});
65+
await fs.writeFile(path.join(packageDirectory, 'package.json'), '{"name":"pkg-a"}');
66+
const testFilename = path.join(packageDirectory, 'test', 'example.test.js');
67+
await fs.mkdir(path.join(packageDirectory, 'test'), {recursive: true});
68+
await fs.writeFile(testFilename, '');
69+
70+
const helperPath = path.join(workspaceRootDirectory, 'eslint-plugin-helper.js');
71+
await fs.writeFile(helperPath, 'module.exports = {load(rootDirectory, options) { return {rootDirectory, options}; }};');
72+
73+
const originalResolveFromSilent = resolveFrom.silent;
74+
resolveFrom.silent = fromDirectory => {
75+
if (fromDirectory === workspaceRootDirectory) {
76+
return undefined;
77+
}
78+
79+
return fromDirectory.startsWith(packageDirectory) ? helperPath : undefined;
80+
};
81+
82+
t.teardown(() => {
83+
resolveFrom.silent = originalResolveFromSilent;
84+
});
85+
86+
const overrides = {files: ['test.js']};
87+
const helper = util.loadAvaHelper(testFilename, overrides);
88+
t.deepEqual(helper, {rootDirectory: workspaceRootDirectory, options: overrides});
89+
});
90+
91+
test.serial('loadAvaHelper resolves hoisted helper outside sub-package root', async t => {
92+
const workspaceRootDirectory = await fs.mkdtemp(path.join(import.meta.dirname, 'tmp-load-ava-helper-hoisted-'));
93+
t.teardown(async () => {
94+
await fs.rm(workspaceRootDirectory, {recursive: true, force: true});
95+
});
96+
97+
// Sub-package has its own AVA config, but AVA is hoisted to the monorepo root.
98+
await fs.writeFile(path.join(workspaceRootDirectory, 'package.json'), '{"name":"workspace"}');
99+
const packageDirectory = path.join(workspaceRootDirectory, 'packages', 'pkg-a');
100+
await fs.mkdir(packageDirectory, {recursive: true});
101+
await fs.writeFile(path.join(packageDirectory, 'package.json'), '{"name":"pkg-a","ava":{}}');
102+
const testFilename = path.join(packageDirectory, 'test', 'example.test.js');
103+
await fs.mkdir(path.join(packageDirectory, 'test'), {recursive: true});
104+
await fs.writeFile(testFilename, '');
105+
106+
// The helper lives at the workspace root (hoisted node_modules), outside the sub-package.
107+
const helperPath = path.join(workspaceRootDirectory, 'node_modules', 'ava', 'eslint-plugin-helper.js');
108+
await fs.mkdir(path.dirname(helperPath), {recursive: true});
109+
await fs.writeFile(helperPath, 'module.exports = {load(rootDirectory, options) { return {rootDirectory, options}; }};');
110+
111+
const originalResolveFromSilent = resolveFrom.silent;
112+
resolveFrom.silent = () => helperPath;
113+
114+
t.teardown(() => {
115+
resolveFrom.silent = originalResolveFromSilent;
116+
});
117+
118+
const overrides = {files: ['test.js']};
119+
const helper = util.loadAvaHelper(testFilename, overrides);
120+
t.deepEqual(helper, {rootDirectory: packageDirectory, options: overrides});
121+
});
122+
123+
const createFixtureDirectory = async t => {
124+
const directory = await fs.mkdtemp(path.join(import.meta.dirname, 'tmp-find-root-'));
125+
t.teardown(async () => {
126+
await fs.rm(directory, {recursive: true, force: true});
127+
});
128+
129+
return directory;
130+
};
131+
132+
test('findProjectRoot: finds directory with ava config in package.json', async t => {
133+
const root = await createFixtureDirectory(t);
134+
await fs.writeFile(path.join(root, 'package.json'), '{"name":"fixture","ava":{}}');
135+
const testFile = path.join(root, 'test', 'foo.test.js');
136+
await fs.mkdir(path.join(root, 'test'), {recursive: true});
137+
await fs.writeFile(testFile, '');
138+
139+
t.is(findProjectRoot(testFile), root);
140+
});
141+
142+
test('findProjectRoot: finds directory with ava.config.js', async t => {
143+
const root = await createFixtureDirectory(t);
144+
await fs.writeFile(path.join(root, 'package.json'), '{"name":"fixture"}');
145+
await fs.writeFile(path.join(root, 'ava.config.js'), '');
146+
const testFile = path.join(root, 'test', 'foo.test.js');
147+
await fs.mkdir(path.join(root, 'test'), {recursive: true});
148+
await fs.writeFile(testFile, '');
149+
150+
t.is(findProjectRoot(testFile), root);
151+
});
152+
153+
test('findProjectRoot: finds directory with ava.config.cjs', async t => {
154+
const root = await createFixtureDirectory(t);
155+
await fs.writeFile(path.join(root, 'package.json'), '{"name":"fixture"}');
156+
await fs.writeFile(path.join(root, 'ava.config.cjs'), '');
157+
const testFile = path.join(root, 'test', 'foo.test.js');
158+
await fs.mkdir(path.join(root, 'test'), {recursive: true});
159+
await fs.writeFile(testFile, '');
160+
161+
t.is(findProjectRoot(testFile), root);
162+
});
163+
164+
test('findProjectRoot: finds directory with ava.config.mjs', async t => {
165+
const root = await createFixtureDirectory(t);
166+
await fs.writeFile(path.join(root, 'package.json'), '{"name":"fixture"}');
167+
await fs.writeFile(path.join(root, 'ava.config.mjs'), '');
168+
const testFile = path.join(root, 'test', 'foo.test.js');
169+
await fs.mkdir(path.join(root, 'test'), {recursive: true});
170+
await fs.writeFile(testFile, '');
171+
172+
t.is(findProjectRoot(testFile), root);
173+
});
174+
175+
test('findProjectRoot: monorepo - finds root with ava config over sub-package', async t => {
176+
const root = await createFixtureDirectory(t);
177+
await fs.writeFile(path.join(root, 'package.json'), '{"name":"monorepo","ava":{}}');
178+
const packageDir = path.join(root, 'packages', 'pkg-a');
179+
await fs.mkdir(packageDir, {recursive: true});
180+
await fs.writeFile(path.join(packageDir, 'package.json'), '{"name":"pkg-a"}');
181+
const testFile = path.join(packageDir, 'test', 'foo.test.js');
182+
await fs.mkdir(path.join(packageDir, 'test'), {recursive: true});
183+
await fs.writeFile(testFile, '');
184+
185+
t.is(findProjectRoot(testFile), root);
186+
});
187+
188+
test('findProjectRoot: config file closer to file wins over package.json ava key higher up', async t => {
189+
const root = await createFixtureDirectory(t);
190+
await fs.writeFile(path.join(root, 'package.json'), '{"name":"monorepo","ava":{}}');
191+
const subDir = path.join(root, 'packages', 'pkg-a');
192+
await fs.mkdir(subDir, {recursive: true});
193+
await fs.writeFile(path.join(subDir, 'package.json'), '{"name":"pkg-a"}');
194+
await fs.writeFile(path.join(subDir, 'ava.config.js'), '');
195+
const testFile = path.join(subDir, 'test', 'foo.test.js');
196+
await fs.mkdir(path.join(subDir, 'test'), {recursive: true});
197+
await fs.writeFile(testFile, '');
198+
199+
t.is(findProjectRoot(testFile), subDir);
200+
});
201+
202+
test('findProjectRoot: skips package.json without ava key', async t => {
203+
const root = await createFixtureDirectory(t);
204+
await fs.writeFile(path.join(root, 'package.json'), '{"name":"fixture"}');
205+
await fs.mkdir(path.join(root, '.git'));
206+
const testFile = path.join(root, 'test', 'foo.test.js');
207+
await fs.mkdir(path.join(root, 'test'), {recursive: true});
208+
await fs.writeFile(testFile, '');
209+
210+
// The fixture package.json has no "ava" key, but traversal should stop
211+
// at the repository boundary and return the nearest package root.
212+
const result = findProjectRoot(testFile);
213+
t.is(result, root);
214+
});
215+
216+
test('findProjectRoot: does not escape repository boundary', async t => {
217+
const outsideDirectory = await createFixtureDirectory(t);
218+
await fs.writeFile(path.join(outsideDirectory, 'package.json'), '{"name":"outside","ava":{}}');
219+
await fs.writeFile(path.join(outsideDirectory, 'ava.config.js'), '');
220+
221+
const root = path.join(outsideDirectory, 'project');
222+
await fs.mkdir(root, {recursive: true});
223+
await fs.mkdir(path.join(root, '.git'));
224+
await fs.writeFile(path.join(root, 'package.json'), '{"name":"fixture"}');
225+
const testFile = path.join(root, 'test', 'foo.test.js');
226+
await fs.mkdir(path.join(root, 'test'), {recursive: true});
227+
await fs.writeFile(testFile, '');
228+
229+
t.is(findProjectRoot(testFile), root);
230+
});
231+
232+
test('findProjectRoot: handles relative filename', async t => {
233+
const root = await createFixtureDirectory(t);
234+
await fs.writeFile(path.join(root, 'package.json'), '{"name":"fixture","ava":{}}');
235+
const testFile = path.join(root, 'test', 'foo.test.js');
236+
await fs.mkdir(path.join(root, 'test'), {recursive: true});
237+
await fs.writeFile(testFile, '');
238+
239+
const relativeTestFile = path.relative(process.cwd(), testFile);
240+
t.is(findProjectRoot(relativeTestFile), root);
241+
});

util.js

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,53 @@
1+
import fs from 'node:fs';
12
import path from 'node:path';
23
import {createRequire} from 'node:module';
3-
import {packageDirectorySync} from 'package-directory';
44
import resolveFrom from 'resolve-from';
55

66
const require = createRequire(import.meta.url);
77
const pkg = require('./package.json');
88

9+
const avaConfigFiles = ['ava.config.js', 'ava.config.cjs', 'ava.config.mjs'];
10+
11+
// Walk up from the file to find the directory where AVA is configured.
12+
// In a monorepo, the nearest package.json may belong to a sub-package
13+
// while AVA is configured at the monorepo root.
14+
export const findProjectRoot = filename => {
15+
let directory = path.resolve(path.dirname(filename));
16+
const {root} = path.parse(directory);
17+
let nearestPackageDirectory;
18+
19+
while (directory !== root) {
20+
if (avaConfigFiles.some(configFile => fs.existsSync(path.join(directory, configFile)))) {
21+
return directory;
22+
}
23+
24+
const packageJsonPath = path.join(directory, 'package.json');
25+
if (fs.existsSync(packageJsonPath)) {
26+
nearestPackageDirectory ??= directory;
27+
28+
try {
29+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
30+
if ('ava' in packageJson) {
31+
return directory;
32+
}
33+
} catch {}
34+
}
35+
36+
if (fs.existsSync(path.join(directory, '.git'))) {
37+
break;
38+
}
39+
40+
directory = path.dirname(directory);
41+
}
42+
43+
return nearestPackageDirectory;
44+
};
45+
946
/* c8 ignore start -- requires a real project with AVA's eslint-plugin-helper installed */
1047
const avaHelperCache = new Map();
1148

1249
export const loadAvaHelper = (filename, overrides) => {
13-
const rootDirectory = packageDirectorySync({cwd: filename});
50+
const rootDirectory = findProjectRoot(filename);
1451
if (!rootDirectory) {
1552
return undefined;
1653
}
@@ -25,7 +62,8 @@ export const loadAvaHelper = (filename, overrides) => {
2562
avaHelperCache.clear();
2663
}
2764

28-
const avaHelperPath = resolveFrom.silent(rootDirectory, 'ava/eslint-plugin-helper');
65+
const avaHelperPath = resolveFrom.silent(path.resolve(path.dirname(filename)), 'ava/eslint-plugin-helper')
66+
?? resolveFrom.silent(rootDirectory, 'ava/eslint-plugin-helper');
2967
if (!avaHelperPath) {
3068
return undefined;
3169
}

0 commit comments

Comments
 (0)