Skip to content

Commit b51564b

Browse files
feat(jest-haste-map): handle injected scm clocks (#10966)
1 parent 1678cd9 commit b51564b

File tree

4 files changed

+171
-24
lines changed

4 files changed

+171
-24
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- `[jest-config, jest-runtime]` Support ESM for files other than `.js` and `.mjs` ([#10823](https://github.com/facebook/jest/pull/10823))
1010
- `[jest-config, jest-runtime]` [**BREAKING**] Use "modern" implementation as default for fake timers ([#10874](https://github.com/facebook/jest/pull/10874))
1111
- `[jest-core]` make `TestWatcher` extend `emittery` ([#10324](https://github.com/facebook/jest/pull/10324))
12+
- `[jest-haste-map]` Handle injected scm clocks ([#10966](https://github.com/facebook/jest/pull/10966))
1213
- `[jest-repl, jest-runner]` [**BREAKING**] Run transforms over environment ([#8751](https://github.com/facebook/jest/pull/8751))
1314
- `[jest-runner]` [**BREAKING**] set exit code to 1 if test logs after teardown ([#10728](https://github.com/facebook/jest/pull/10728))
1415
- `[jest-snapshot]` [**BREAKING**] Make prettier optional for inline snapshots - fall back to string replacement ([#7792](https://github.com/facebook/jest/pull/7792))

packages/jest-haste-map/src/crawlers/__tests__/watchman.test.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,4 +589,89 @@ describe('watchman watch', () => {
589589
expect(calls[0][0]).toEqual(['list-capabilities']);
590590
expect(calls[2][0][2].fields).not.toContain('content.sha1hex');
591591
});
592+
593+
test('source control query', async () => {
594+
mockResponse = {
595+
'list-capabilities': {
596+
[undefined]: {
597+
capabilities: ['field-content.sha1hex'],
598+
},
599+
},
600+
query: {
601+
[ROOT_MOCK]: {
602+
clock: {
603+
clock: 'c:1608612057:79675:1:139410',
604+
scm: {
605+
mergebase: 'master',
606+
'mergebase-with': 'master',
607+
},
608+
},
609+
files: [
610+
{
611+
exists: true,
612+
mtime_ms: {toNumber: () => 42},
613+
name: 'fruits/kiwi.js',
614+
size: 40,
615+
},
616+
{
617+
exists: false,
618+
mtime_ms: null,
619+
name: 'fruits/tomato.js',
620+
size: 0,
621+
},
622+
],
623+
// Watchman is going to tell us that we have a fresh instance.
624+
is_fresh_instance: true,
625+
version: '4.5.0',
626+
},
627+
},
628+
'watch-project': WATCH_PROJECT_MOCK,
629+
};
630+
631+
// Start with a source-control clock.
632+
const clocks = createMap({
633+
'': {scm: {'mergebase-with': 'master'}},
634+
});
635+
636+
const {changedFiles, hasteMap, removedFiles} = await watchmanCrawl({
637+
data: {
638+
clocks,
639+
files: mockFiles,
640+
},
641+
extensions: ['js', 'json'],
642+
ignore: pearMatcher,
643+
rootDir: ROOT_MOCK,
644+
roots: ROOTS,
645+
});
646+
647+
// The object was reused.
648+
expect(hasteMap.files).toBe(mockFiles);
649+
650+
// Transformed into a normal clock.
651+
expect(hasteMap.clocks).toEqual(
652+
createMap({
653+
'': 'c:1608612057:79675:1:139410',
654+
}),
655+
);
656+
657+
expect(changedFiles).toEqual(
658+
createMap({
659+
[KIWI_RELATIVE]: ['', 42, 40, 0, '', null],
660+
}),
661+
);
662+
663+
expect(hasteMap.files).toEqual(
664+
createMap({
665+
[KIWI_RELATIVE]: ['', 42, 40, 0, '', null],
666+
[MELON_RELATIVE]: ['', 33, 43, 0, '', null],
667+
[STRAWBERRY_RELATIVE]: ['', 30, 40, 0, '', null],
668+
}),
669+
);
670+
671+
expect(removedFiles).toEqual(
672+
createMap({
673+
[TOMATO_RELATIVE]: ['', 31, 41, 0, '', null],
674+
}),
675+
);
676+
});
592677
});

packages/jest-haste-map/src/crawlers/watchman.ts

Lines changed: 83 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,34 @@ import type {
2020

2121
type WatchmanRoots = Map<string, Array<string>>;
2222

23+
type WatchmanListCapabilitiesResponse = {
24+
capabilities: Array<string>;
25+
};
26+
27+
type WatchmanWatchProjectResponse = {
28+
watch: string;
29+
relative_path: string;
30+
};
31+
32+
type WatchmanQueryResponse = {
33+
warning?: string;
34+
is_fresh_instance: boolean;
35+
version: string;
36+
clock:
37+
| string
38+
| {
39+
scm: {'mergebase-with': string; mergebase: string};
40+
clock: string;
41+
};
42+
files: Array<{
43+
name: string;
44+
exists: boolean;
45+
mtime_ms: number | {toNumber: () => number};
46+
size: number;
47+
'content.sha1hex'?: string;
48+
}>;
49+
};
50+
2351
const watchmanURL = 'https://facebook.github.io/watchman/docs/troubleshooting';
2452

2553
function WatchmanError(error: Error): Error {
@@ -49,16 +77,17 @@ export = async function watchmanCrawl(
4977
let clientError;
5078
client.on('error', error => (clientError = WatchmanError(error)));
5179

52-
// TODO: type better than `any`
53-
const cmd = (...args: Array<any>): Promise<any> =>
80+
const cmd = <T>(...args: Array<any>): Promise<T> =>
5481
new Promise((resolve, reject) =>
5582
client.command(args, (error, result) =>
5683
error ? reject(WatchmanError(error)) : resolve(result),
5784
),
5885
);
5986

6087
if (options.computeSha1) {
61-
const {capabilities} = await cmd('list-capabilities');
88+
const {capabilities} = await cmd<WatchmanListCapabilitiesResponse>(
89+
'list-capabilities',
90+
);
6291

6392
if (capabilities.indexOf('field-content.sha1hex') !== -1) {
6493
fields.push('content.sha1hex');
@@ -71,7 +100,10 @@ export = async function watchmanCrawl(
71100
const watchmanRoots = new Map();
72101
await Promise.all(
73102
roots.map(async root => {
74-
const response = await cmd('watch-project', root);
103+
const response = await cmd<WatchmanWatchProjectResponse>(
104+
'watch-project',
105+
root,
106+
);
75107
const existing = watchmanRoots.get(response.watch);
76108
// A root can only be filtered if it was never seen with a
77109
// relative_path before.
@@ -96,7 +128,7 @@ export = async function watchmanCrawl(
96128
}
97129

98130
async function queryWatchmanForDirs(rootProjectDirMappings: WatchmanRoots) {
99-
const files = new Map();
131+
const results = new Map<string, WatchmanQueryResponse>();
100132
let isFresh = false;
101133
await Promise.all(
102134
Array.from(rootProjectDirMappings).map(
@@ -121,35 +153,58 @@ export = async function watchmanCrawl(
121153
}
122154
}
123155

124-
const relativeRoot = fastPath.relative(rootDir, root);
125-
const query = clocks.has(relativeRoot)
126-
? // Use the `since` generator if we have a clock available
127-
{expression, fields, since: clocks.get(relativeRoot)}
128-
: // Otherwise use the `glob` filter
129-
{expression, fields, glob, glob_includedotfiles: true};
130-
131-
const response = await cmd('query', root, query);
156+
// Jest is only going to store one type of clock; a string that
157+
// represents a local clock. However, the Watchman crawler supports
158+
// a second type of clock that can be written by automation outside of
159+
// Jest, called an "scm query", which fetches changed files based on
160+
// source control mergebases. The reason this is necessary is because
161+
// local clocks are not portable across systems, but scm queries are.
162+
// By using scm queries, we can create the haste map on a different
163+
// system and import it, transforming the clock into a local clock.
164+
const since = clocks.get(fastPath.relative(rootDir, root));
165+
166+
const query =
167+
since !== undefined
168+
? // Use the `since` generator if we have a clock available
169+
{expression, fields, since}
170+
: // Otherwise use the `glob` filter
171+
{expression, fields, glob, glob_includedotfiles: true};
172+
173+
const response = await cmd<WatchmanQueryResponse>(
174+
'query',
175+
root,
176+
query,
177+
);
132178

133179
if ('warning' in response) {
134180
console.warn('watchman warning: ', response.warning);
135181
}
136182

137-
isFresh = isFresh || response.is_fresh_instance;
138-
files.set(root, response);
183+
// When a source-control query is used, we ignore the "is fresh"
184+
// response from Watchman because it will be true despite the query
185+
// being incremental.
186+
const isSourceControlQuery =
187+
typeof since !== 'string' &&
188+
since?.scm?.['mergebase-with'] !== undefined;
189+
if (!isSourceControlQuery) {
190+
isFresh = isFresh || response.is_fresh_instance;
191+
}
192+
193+
results.set(root, response);
139194
},
140195
),
141196
);
142197

143198
return {
144-
files,
145199
isFresh,
200+
results,
146201
};
147202
}
148203

149204
let files = data.files;
150205
let removedFiles = new Map();
151206
const changedFiles = new Map();
152-
let watchmanFiles: Map<string, any>;
207+
let results: Map<string, WatchmanQueryResponse>;
153208
let isFresh = false;
154209
try {
155210
const watchmanRoots = await getWatchmanRoots(roots);
@@ -163,7 +218,7 @@ export = async function watchmanCrawl(
163218
isFresh = true;
164219
}
165220

166-
watchmanFiles = watchmanFileResults.files;
221+
results = watchmanFileResults.results;
167222
} finally {
168223
client.end();
169224
}
@@ -172,11 +227,16 @@ export = async function watchmanCrawl(
172227
throw clientError;
173228
}
174229

175-
// TODO: remove non-null
176-
for (const [watchRoot, response] of watchmanFiles!) {
230+
for (const [watchRoot, response] of results) {
177231
const fsRoot = normalizePathSep(watchRoot);
178232
const relativeFsRoot = fastPath.relative(rootDir, fsRoot);
179-
clocks.set(relativeFsRoot, response.clock);
233+
clocks.set(
234+
relativeFsRoot,
235+
// Ensure we persist only the local clock.
236+
typeof response.clock === 'string'
237+
? response.clock
238+
: response.clock.clock,
239+
);
180240

181241
for (const fileData of response.files) {
182242
const filePath = fsRoot + path.sep + normalizePathSep(fileData.name);
@@ -209,7 +269,7 @@ export = async function watchmanCrawl(
209269

210270
let sha1hex = fileData['content.sha1hex'];
211271
if (typeof sha1hex !== 'string' || sha1hex.length !== 40) {
212-
sha1hex = null;
272+
sha1hex = undefined;
213273
}
214274

215275
let nextData: FileMetaData;
@@ -231,7 +291,7 @@ export = async function watchmanCrawl(
231291
];
232292
} else {
233293
// See ../constants.ts
234-
nextData = ['', mtime, size, 0, '', sha1hex];
294+
nextData = ['', mtime, size, 0, '', sha1hex ?? null];
235295
}
236296

237297
files.set(relativeFilePath, nextData);

packages/jest-haste-map/src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ export type FileMetaData = [
5555

5656
export type MockData = Map<string, Config.Path>;
5757
export type ModuleMapData = Map<string, ModuleMapItem>;
58-
export type WatchmanClocks = Map<Config.Path, string>;
58+
export type WatchmanClockSpec = string | {scm: {'mergebase-with': string}};
59+
export type WatchmanClocks = Map<Config.Path, WatchmanClockSpec>;
5960
export type HasteRegExp = RegExp | ((str: string) => boolean);
6061

6162
export type DuplicatesSet = Map<string, /* type */ number>;

0 commit comments

Comments
 (0)