Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(jest-haste-map): handle injected scm clocks #10966

Merged
merged 4 commits into from Dec 22, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -8,6 +8,7 @@
- `[jest-config, jest-runtime]` Support ESM for files other than `.js` and `.mjs` ([#10823](https://github.com/facebook/jest/pull/10823))
- `[jest-config, jest-runtime]` [**BREAKING**] Use "modern" implementation as default for fake timers ([#10874](https://github.com/facebook/jest/pull/10874))
- `[jest-core]` make `TestWatcher` extend `emittery` ([#10324](https://github.com/facebook/jest/pull/10324))
- `[jest-haste-map]` Handle injected scm clocks ([#10966](https://github.com/facebook/jest/pull/10966))
- `[jest-repl, jest-runner]` [**BREAKING**] Run transforms over environment ([#8751](https://github.com/facebook/jest/pull/8751))
- `[jest-runner]` [**BREAKING**] set exit code to 1 if test logs after teardown ([#10728](https://github.com/facebook/jest/pull/10728))
- `[jest-snapshot]` [**BREAKING**] Make prettier optional for inline snapshots - fall back to string replacement ([#7792](https://github.com/facebook/jest/pull/7792))
Expand Down
85 changes: 85 additions & 0 deletions packages/jest-haste-map/src/crawlers/__tests__/watchman.test.js
Expand Up @@ -589,4 +589,89 @@ describe('watchman watch', () => {
expect(calls[0][0]).toEqual(['list-capabilities']);
expect(calls[2][0][2].fields).not.toContain('content.sha1hex');
});

test('source control query', async () => {
scotthovestadt marked this conversation as resolved.
Show resolved Hide resolved
mockResponse = {
'list-capabilities': {
[undefined]: {
capabilities: ['field-content.sha1hex'],
},
},
query: {
[ROOT_MOCK]: {
clock: {
clock: 'c:1608612057:79675:1:139410',
scm: {
mergebase: 'master',
'mergebase-with': 'master',
},
},
files: [
{
exists: true,
mtime_ms: {toNumber: () => 42},
name: 'fruits/kiwi.js',
size: 40,
},
{
exists: false,
mtime_ms: null,
name: 'fruits/tomato.js',
size: 0,
},
],
// Watchman is going to tell us that we have a fresh instance.
is_fresh_instance: true,
version: '4.5.0',
},
},
'watch-project': WATCH_PROJECT_MOCK,
};

// Start with a source-control clock.
const clocks = createMap({
'': {scm: {'mergebase-with': 'master'}},
});

const {changedFiles, hasteMap, removedFiles} = await watchmanCrawl({
data: {
clocks,
files: mockFiles,
},
extensions: ['js', 'json'],
ignore: pearMatcher,
rootDir: ROOT_MOCK,
roots: ROOTS,
});

// The object was reused.
expect(hasteMap.files).toBe(mockFiles);

// Transformed into a normal clock.
expect(hasteMap.clocks).toEqual(
createMap({
'': 'c:1608612057:79675:1:139410',
}),
);

expect(changedFiles).toEqual(
createMap({
[KIWI_RELATIVE]: ['', 42, 40, 0, '', null],
}),
);

expect(hasteMap.files).toEqual(
createMap({
[KIWI_RELATIVE]: ['', 42, 40, 0, '', null],
[MELON_RELATIVE]: ['', 33, 43, 0, '', null],
[STRAWBERRY_RELATIVE]: ['', 30, 40, 0, '', null],
}),
);

expect(removedFiles).toEqual(
createMap({
[TOMATO_RELATIVE]: ['', 31, 41, 0, '', null],
}),
);
});
});
106 changes: 83 additions & 23 deletions packages/jest-haste-map/src/crawlers/watchman.ts
Expand Up @@ -20,6 +20,34 @@ import type {

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

type WatchmanListCapabilitiesResponse = {
capabilities: Array<string>;
};

type WatchmanWatchProjectResponse = {
watch: string;
relative_path: string;
};

type WatchmanQueryResponse = {
warning?: string;
is_fresh_instance: boolean;
version: string;
clock:
| string
| {
scm: {'mergebase-with': string; mergebase: string};
clock: string;
};
files: Array<{
name: string;
exists: boolean;
mtime_ms: number | {toNumber: () => number};
size: number;
'content.sha1hex'?: string;
}>;
};

const watchmanURL = 'https://facebook.github.io/watchman/docs/troubleshooting';

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

// TODO: type better than `any`
const cmd = (...args: Array<any>): Promise<any> =>
const cmd = <T>(...args: Array<any>): Promise<T> =>
new Promise((resolve, reject) =>
client.command(args, (error, result) =>
error ? reject(WatchmanError(error)) : resolve(result),
),
);

if (options.computeSha1) {
const {capabilities} = await cmd('list-capabilities');
const {capabilities} = await cmd<WatchmanListCapabilitiesResponse>(
'list-capabilities',
);

if (capabilities.indexOf('field-content.sha1hex') !== -1) {
fields.push('content.sha1hex');
Expand All @@ -71,7 +100,10 @@ export = async function watchmanCrawl(
const watchmanRoots = new Map();
await Promise.all(
roots.map(async root => {
const response = await cmd('watch-project', root);
const response = await cmd<WatchmanWatchProjectResponse>(
'watch-project',
root,
);
const existing = watchmanRoots.get(response.watch);
// A root can only be filtered if it was never seen with a
// relative_path before.
Expand All @@ -96,7 +128,7 @@ export = async function watchmanCrawl(
}

async function queryWatchmanForDirs(rootProjectDirMappings: WatchmanRoots) {
const files = new Map();
const results = new Map<string, WatchmanQueryResponse>();
let isFresh = false;
await Promise.all(
Array.from(rootProjectDirMappings).map(
Expand All @@ -121,35 +153,58 @@ export = async function watchmanCrawl(
}
}

const relativeRoot = fastPath.relative(rootDir, root);
const query = clocks.has(relativeRoot)
? // Use the `since` generator if we have a clock available
{expression, fields, since: clocks.get(relativeRoot)}
: // Otherwise use the `glob` filter
{expression, fields, glob, glob_includedotfiles: true};

const response = await cmd('query', root, query);
// Jest is only going to store one type of clock; a string that
// represents a local clock. However, the Watchman crawler supports
// a second type of clock that can be written by automation outside of
// Jest, called an "scm query", which fetches changed files based on
// source control mergebases. The reason this is necessary is because
// local clocks are not portable across systems, but scm queries are.
// By using scm queries, we can create the haste map on a different
// system and import it, transforming the clock into a local clock.
const since = clocks.get(fastPath.relative(rootDir, root));

const query =
since !== undefined
? // Use the `since` generator if we have a clock available
{expression, fields, since}
: // Otherwise use the `glob` filter
{expression, fields, glob, glob_includedotfiles: true};

const response = await cmd<WatchmanQueryResponse>(
'query',
root,
query,
);

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

isFresh = isFresh || response.is_fresh_instance;
files.set(root, response);
// When a source-control query is used, we ignore the "is fresh"
// response from Watchman because it will be true despite the query
// being incremental.
const isSourceControlQuery =
typeof since !== 'string' &&
since?.scm?.['mergebase-with'] !== undefined;
if (!isSourceControlQuery) {
isFresh = isFresh || response.is_fresh_instance;
}

results.set(root, response);
},
),
);

return {
files,
isFresh,
results,
};
}

let files = data.files;
let removedFiles = new Map();
const changedFiles = new Map();
let watchmanFiles: Map<string, any>;
let results: Map<string, WatchmanQueryResponse>;
let isFresh = false;
try {
const watchmanRoots = await getWatchmanRoots(roots);
Expand All @@ -163,7 +218,7 @@ export = async function watchmanCrawl(
isFresh = true;
}

watchmanFiles = watchmanFileResults.files;
results = watchmanFileResults.results;
} finally {
client.end();
}
Expand All @@ -172,11 +227,16 @@ export = async function watchmanCrawl(
throw clientError;
}

// TODO: remove non-null
for (const [watchRoot, response] of watchmanFiles!) {
for (const [watchRoot, response] of results) {
const fsRoot = normalizePathSep(watchRoot);
const relativeFsRoot = fastPath.relative(rootDir, fsRoot);
clocks.set(relativeFsRoot, response.clock);
clocks.set(
relativeFsRoot,
// Ensure we persist only the local clock.
typeof response.clock === 'string'
? response.clock
: response.clock.clock,
);

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

let sha1hex = fileData['content.sha1hex'];
if (typeof sha1hex !== 'string' || sha1hex.length !== 40) {
sha1hex = null;
sha1hex = undefined;
}

let nextData: FileMetaData;
Expand All @@ -231,7 +291,7 @@ export = async function watchmanCrawl(
];
} else {
// See ../constants.ts
nextData = ['', mtime, size, 0, '', sha1hex];
nextData = ['', mtime, size, 0, '', sha1hex ?? null];
}

files.set(relativeFilePath, nextData);
Expand Down
3 changes: 2 additions & 1 deletion packages/jest-haste-map/src/types.ts
Expand Up @@ -55,7 +55,8 @@ export type FileMetaData = [

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

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