diff --git a/packages/metro-file-map/src/__tests__/index-test.js b/packages/metro-file-map/src/__tests__/index-test.js index e4385ba90d..835f29653f 100644 --- a/packages/metro-file-map/src/__tests__/index-test.js +++ b/packages/metro-file-map/src/__tests__/index-test.js @@ -17,9 +17,9 @@ function mockHashContents(contents) { return crypto.createHash('sha1').update(contents).digest('hex'); } -jest.mock('../lib/canUseWatchman', () => ({ +jest.mock('../lib/checkWatchmanCapabilities', () => ({ __esModule: true, - default: async () => true, + default: async () => {}, })); jest.mock('jest-worker', () => ({ diff --git a/packages/metro-file-map/src/index.js b/packages/metro-file-map/src/index.js index 93da064df0..eaaac07aa0 100644 --- a/packages/metro-file-map/src/index.js +++ b/packages/metro-file-map/src/index.js @@ -37,7 +37,7 @@ import {DiskCacheManager} from './cache/DiskCacheManager'; import H from './constants'; import getMockName from './getMockName'; import HasteFS from './HasteFS'; -import canUseWatchman from './lib/canUseWatchman'; +import checkWatchmanCapabilities from './lib/checkWatchmanCapabilities'; import deepCloneInternalData from './lib/deepCloneInternalData'; import * as fastPath from './lib/fast_path'; import getPlatformExtension from './lib/getPlatformExtension'; @@ -1158,7 +1158,19 @@ export default class HasteMap extends EventEmitter { return false; } if (!this._canUseWatchmanPromise) { - this._canUseWatchmanPromise = canUseWatchman(); + // TODO: Ensure minimum capabilities here + this._canUseWatchmanPromise = checkWatchmanCapabilities([]) + .then(() => true) + .catch(e => { + // TODO: Advise people to either install Watchman or set + // `useWatchman: false` here? + this._options.perfLogger?.annotate({ + string: { + watchmanFailedCapabilityCheck: e?.message ?? '[missing]', + }, + }); + return false; + }); } return this._canUseWatchmanPromise; } diff --git a/packages/metro-file-map/src/lib/__tests__/canUseWatchman-test.js b/packages/metro-file-map/src/lib/__tests__/canUseWatchman-test.js deleted file mode 100644 index 7b7b3d90e7..0000000000 --- a/packages/metro-file-map/src/lib/__tests__/canUseWatchman-test.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @format - * @flow strict - */ - -import canUseWatchman from '../canUseWatchman'; - -const mockExecFile = jest.fn(); -jest.mock('child_process', () => ({ - execFile: (...args) => mockExecFile(...args), -})); - -describe('canUseWatchman', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('executes watchman --version and returns true on success', async () => { - mockExecFile.mockImplementation((file, args, cb) => { - expect(file).toBe('watchman'); - expect(args).toStrictEqual(['--version']); - cb(null, {stdout: 'v123'}); - }); - expect(await canUseWatchman()).toBe(true); - expect(mockExecFile).toHaveBeenCalledWith( - 'watchman', - ['--version'], - expect.any(Function), - ); - }); - - it('returns false when execFile fails', async () => { - mockExecFile.mockImplementation((file, args, cb) => { - cb(new Error()); - }); - expect(await canUseWatchman()).toBe(false); - expect(mockExecFile).toHaveBeenCalled(); - }); -}); diff --git a/packages/metro-file-map/src/lib/__tests__/checkWatchmanCapabilities-test.js b/packages/metro-file-map/src/lib/__tests__/checkWatchmanCapabilities-test.js new file mode 100644 index 0000000000..a67b1fd035 --- /dev/null +++ b/packages/metro-file-map/src/lib/__tests__/checkWatchmanCapabilities-test.js @@ -0,0 +1,83 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict + */ + +import checkWatchmanCapabilities from '../checkWatchmanCapabilities'; + +const mockExecFile = jest.fn(); +jest.mock('child_process', () => ({ + execFile: (...args) => mockExecFile(...args), +})); + +const mockSuccessResponse = JSON.stringify({ + version: 'v123', + capabilities: ['c1', 'c2'], +}); + +function setMockExecFileResponse(err: mixed, stdout?: mixed) { + mockExecFile.mockImplementation((file, args, cb) => { + expect(file).toBe('watchman'); + cb(err, err == null ? {stdout} : null); + }); +} + +describe('checkWatchmanCapabilities', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('executes watchman list-capabilities and resolves on success', async () => { + setMockExecFileResponse(null, mockSuccessResponse); + await expect(checkWatchmanCapabilities(['c1', 'c2'])).resolves.toEqual(); + expect(mockExecFile).toHaveBeenCalledWith( + 'watchman', + [ + 'list-capabilities', + '--output-encoding=json', + '--no-pretty', + '--no-spawn', + ], + expect.any(Function), + ); + }); + + it('rejects when execFile reports ENOENT', async () => { + setMockExecFileResponse({code: 'ENOENT'}); + await expect(checkWatchmanCapabilities([])).rejects.toMatchInlineSnapshot( + `[Error: Watchman is not installed or not available on PATH]`, + ); + expect(mockExecFile).toHaveBeenCalled(); + }); + + it('rejects when execFile fails', async () => { + setMockExecFileResponse(new Error('execFile error')); + await expect(checkWatchmanCapabilities([])).rejects.toMatchInlineSnapshot( + `[Error: execFile error]`, + ); + expect(mockExecFile).toHaveBeenCalled(); + }); + + it('rejects when the response is not JSON', async () => { + setMockExecFileResponse(null, 'not json'); + await expect(checkWatchmanCapabilities([])).rejects.toMatchInlineSnapshot( + `[Error: Failed to parse response from \`watchman list-capabilities\`]`, + ); + expect(mockExecFile).toHaveBeenCalled(); + }); + + it('rejects when we are missing a required capability', async () => { + setMockExecFileResponse(null, mockSuccessResponse); + await expect( + checkWatchmanCapabilities(['c1', 'other-cap']), + ).rejects.toMatchInlineSnapshot( + `[Error: The installed version of Watchman (v123) is missing required capabilities: other-cap]`, + ); + expect(mockExecFile).toHaveBeenCalled(); + }); +}); diff --git a/packages/metro-file-map/src/lib/canUseWatchman.js b/packages/metro-file-map/src/lib/canUseWatchman.js deleted file mode 100644 index a9234fe243..0000000000 --- a/packages/metro-file-map/src/lib/canUseWatchman.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @format - * @flow strict - */ - -import {execFile} from 'child_process'; -import {promisify} from 'util'; - -export default async function canUseWatchman(): Promise { - try { - await promisify(execFile)('watchman', ['--version']); - return true; - } catch { - return false; - } -} diff --git a/packages/metro-file-map/src/lib/checkWatchmanCapabilities.js b/packages/metro-file-map/src/lib/checkWatchmanCapabilities.js new file mode 100644 index 0000000000..4a2ca58c21 --- /dev/null +++ b/packages/metro-file-map/src/lib/checkWatchmanCapabilities.js @@ -0,0 +1,67 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict + */ + +import {execFile} from 'child_process'; +import {promisify} from 'util'; + +export default async function checkWatchmanCapabilities( + requiredCapabilities: $ReadOnlyArray, +): Promise { + const execFilePromise: ( + cmd: string, + args: $ReadOnlyArray, + ) => Promise<{stdout: string}> = promisify(execFile); + + let rawResponse; + try { + const result = await execFilePromise('watchman', [ + 'list-capabilities', + '--output-encoding=json', + '--no-pretty', + '--no-spawn', // The client can answer this, so don't spawn a server + ]); + rawResponse = result.stdout; + } catch (e) { + if (e?.code === 'ENOENT') { + throw new Error('Watchman is not installed or not available on PATH'); + } + throw e; + } + + let parsedResponse; + try { + parsedResponse = (JSON.parse(rawResponse): mixed); + } catch { + throw new Error( + 'Failed to parse response from `watchman list-capabilities`', + ); + } + + if ( + parsedResponse == null || + typeof parsedResponse !== 'object' || + typeof parsedResponse.version !== 'string' || + !Array.isArray(parsedResponse.capabilities) + ) { + throw new Error('Unexpected response from `watchman list-capabilities`'); + } + const version = parsedResponse.version; + const capabilities = new Set(parsedResponse.capabilities); + const missingCapabilities = requiredCapabilities.filter( + requiredCapability => !capabilities.has(requiredCapability), + ); + if (missingCapabilities.length > 0) { + throw new Error( + `The installed version of Watchman (${version}) is missing required capabilities: ${missingCapabilities.join( + ', ', + )}`, + ); + } +}