-
Notifications
You must be signed in to change notification settings - Fork 614
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add watcher integration tests (#897)
Summary: Pull Request resolved: #897 Add tests exercising Watchman, FSEvents and Node watchers, to verify symlink behaviour in particular, and provide more confidence for upcoming changes against multiple target platforms. Exporting to a PR to check the run on CircleCI. Changelog: Internal Reviewed By: motiz88 Differential Revision: D41336902 fbshipit-source-id: 4715d776466b256a0c0f32ff6148dfda038d4a5e
- Loading branch information
1 parent
77fd46c
commit 50dd7a2
Showing
1 changed file
with
294 additions
and
0 deletions.
There are no files selected for viewing
294 changes: 294 additions & 0 deletions
294
packages/metro-file-map/src/watchers/__tests__/integration-test.js
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,294 @@ | ||
/** | ||
* 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. | ||
* | ||
* @flow strict-local | ||
* @format | ||
* @oncall react_native | ||
*/ | ||
|
||
import type {WatcherOptions} from '../common'; | ||
import type {Stats} from 'fs'; | ||
|
||
import NodeWatcher from '../NodeWatcher'; | ||
import FSEventsWatcher from '../FSEventsWatcher'; | ||
import WatchmanWatcher from '../WatchmanWatcher'; | ||
import {execSync} from 'child_process'; | ||
import os from 'os'; | ||
import {promises as fsPromises} from 'fs'; | ||
import invariant from 'invariant'; | ||
import {join} from 'path'; | ||
const {mkdtemp, mkdir, writeFile, rm, realpath, symlink, unlink} = fsPromises; | ||
|
||
jest.useRealTimers(); | ||
|
||
// At runtime we use a more sophisticated + robust Watchman capability check, | ||
// but this simple heuristic is fast to check, synchronous (we can't | ||
// asynchronously skip tests: https://github.com/facebook/jest/issues/8604), | ||
// and will tend to exercise our Watchman tests whenever possible. | ||
const isWatchmanOnPath = () => { | ||
try { | ||
execSync(os.platform() === 'windows' ? 'where watchman' : 'which watchman'); | ||
return true; | ||
} catch { | ||
return false; | ||
} | ||
}; | ||
|
||
// `null` Watchers will be marked as skipped tests. | ||
const WATCHERS: $ReadOnly<{ | ||
[key: string]: | ||
| Class<NodeWatcher> | ||
| Class<FSEventsWatcher> | ||
| Class<WatchmanWatcher> | ||
| null, | ||
}> = { | ||
Node: NodeWatcher, | ||
Watchman: isWatchmanOnPath() ? WatchmanWatcher : null, | ||
FSEvents: FSEventsWatcher.isSupported() ? FSEventsWatcher : null, | ||
}; | ||
|
||
test('FSEventsWatcher is supported if and only if darwin', () => { | ||
expect(FSEventsWatcher.isSupported()).toBe(os.platform() === 'darwin'); | ||
}); | ||
|
||
describe.each(Object.keys(WATCHERS))( | ||
'Watcher integration tests: %s', | ||
watcherName => { | ||
let appRoot; | ||
let cookieCount = 1; | ||
let watcherInstance; | ||
let watchRoot; | ||
let nextEvent: ( | ||
afterFn: () => Promise<void>, | ||
) => Promise<{eventType: string, path: string, stat?: Stats}>; | ||
let untilEvent: ( | ||
afterFn: () => Promise<void>, | ||
expectedPath: string, | ||
expectedEvent: 'add' | 'delete' | 'change', | ||
) => Promise<void>; | ||
let allEvents: ( | ||
afterFn: () => Promise<void>, | ||
events: $ReadOnlyArray<[string, 'add' | 'delete' | 'change']>, | ||
opts?: {rejectUnexpected: boolean}, | ||
) => Promise<void>; | ||
|
||
const Watcher = WATCHERS[watcherName]; | ||
|
||
// If all tests are skipped, Jest will not run before/after hooks either. | ||
const maybeTest = Watcher ? test : test.skip; | ||
|
||
beforeAll(async () => { | ||
const tmpDir = await mkdtemp( | ||
join(os.tmpdir(), `metro-watcher-${watcherName}-test-`), | ||
); | ||
|
||
// os.tmpdir() on macOS gives us a symlink /var/foo -> /private/var/foo, | ||
// we normalise it with realpath so that watchers report predictable | ||
// root-relative paths for change events. | ||
watchRoot = await realpath(tmpDir); | ||
await writeFile(join(watchRoot, '.watchmanconfig'), '{}'); | ||
|
||
// Perform all writes one level deeper than the watch root, so that we | ||
// can reset file fixtures without re-establishing a watch. | ||
appRoot = join(watchRoot, 'app'); | ||
|
||
const opts: WatcherOptions = { | ||
dot: true, | ||
glob: [], | ||
// We need to ignore `.watchmanconfig` to keep these tests stable. | ||
// Even though we write it before initialising watchers, OS-level | ||
// delays/debouncing(?) can mean the write is *sometimes* reported by | ||
// the watcher. | ||
ignored: /\.watchmanconfig/, | ||
watchmanDeferStates: [], | ||
}; | ||
|
||
nextEvent = afterFn => | ||
Promise.all([ | ||
new Promise((resolve, reject) => { | ||
const listener = ( | ||
eventType: string, | ||
path: string, | ||
root: string, | ||
stat: Stats, | ||
) => { | ||
if (path === '') { | ||
// FIXME: FSEventsWatcher sometimes reports 'change' events to | ||
// the watch root. | ||
return; | ||
} | ||
watcherInstance.removeListener('all', listener); | ||
if (root !== watchRoot) { | ||
reject(new Error(`Expected root ${watchRoot}, got ${root}`)); | ||
} | ||
resolve({eventType, path, stat}); | ||
}; | ||
watcherInstance.on('all', listener); | ||
}), | ||
afterFn(), | ||
]).then(([event]) => event); | ||
|
||
untilEvent = (afterFn, expectedPath, expectedEventType) => | ||
allEvents(afterFn, [[expectedPath, expectedEventType]], { | ||
rejectUnexpected: false, | ||
}); | ||
|
||
allEvents = (afterFn, expectedEvents, {rejectUnexpected = true} = {}) => | ||
Promise.all([ | ||
new Promise(async (resolve, reject) => { | ||
const tupleToKey = (tuple: $ReadOnlyArray<string>) => | ||
tuple.join('\0'); | ||
const allEventKeys = new Set( | ||
expectedEvents.map(tuple => tupleToKey(tuple)), | ||
); | ||
const listener = (eventType: string, path: string) => { | ||
if (path === '') { | ||
// FIXME: FSEventsWatcher sometimes reports 'change' events to | ||
// the watch root. | ||
return; | ||
} | ||
const receivedKey = tupleToKey([path, eventType]); | ||
if (allEventKeys.has(receivedKey)) { | ||
allEventKeys.delete(receivedKey); | ||
if (allEventKeys.size === 0) { | ||
watcherInstance.removeListener('all', listener); | ||
resolve(); | ||
} | ||
} else if (rejectUnexpected) { | ||
watcherInstance.removeListener('all', listener); | ||
reject(new Error(`Unexpected event: ${eventType} ${path}.`)); | ||
} | ||
}; | ||
watcherInstance.on('all', listener); | ||
}), | ||
afterFn(), | ||
]).then(() => {}); | ||
|
||
invariant(Watcher, 'Use of maybeTest should ensure Watcher is non-null'); | ||
watcherInstance = new Watcher(watchRoot, opts); | ||
await new Promise(resolve => { | ||
watcherInstance.on('ready', resolve); | ||
}); | ||
}); | ||
|
||
beforeEach(async () => { | ||
// Discard events before app add - sometimes pre-init events are reported | ||
// after the watcher is ready. | ||
await untilEvent(() => mkdir(appRoot), 'app', 'add'); | ||
}); | ||
|
||
afterEach(async () => { | ||
// Ensure there are no unexpected events after a test completes, to | ||
// catch double-counting, unexpected symlink traversal, etc. | ||
const cookieName = `cookie-${++cookieCount}`; | ||
expect( | ||
await nextEvent(() => writeFile(join(watchRoot, cookieName), '')), | ||
).toMatchObject({path: cookieName, eventType: 'add'}); | ||
// Cleanup and wait until the app root deletion is reported - this should | ||
// be the last cleanup event emitted. | ||
await untilEvent(() => rm(appRoot, {recursive: true}), 'app', 'delete'); | ||
}); | ||
|
||
afterAll(async () => { | ||
await watcherInstance.close(); | ||
await rm(watchRoot, {recursive: true}); | ||
}); | ||
|
||
maybeTest('detects a new, changed, deleted file', async () => { | ||
const testFile = join(appRoot, 'test.js'); | ||
const relativePath = join('app', 'test.js'); | ||
expect( | ||
await nextEvent(() => writeFile(testFile, 'hello world')), | ||
).toStrictEqual({ | ||
path: relativePath, | ||
eventType: 'add', | ||
stat: expect.any(Object), | ||
}); | ||
expect( | ||
await nextEvent(() => writeFile(testFile, 'brave new world')), | ||
).toStrictEqual({ | ||
path: relativePath, | ||
eventType: 'change', | ||
stat: expect.any(Object), | ||
}); | ||
expect(await nextEvent(() => unlink(testFile))).toStrictEqual({ | ||
path: relativePath, | ||
eventType: 'delete', | ||
stat: undefined, | ||
}); | ||
}); | ||
|
||
// $FlowFixMe: Update RN's Jest libdefs so that `skip` has an `each` static | ||
maybeTest.each([ | ||
join('.', 'foo'), | ||
join('.', 'foo', 'bar.js'), | ||
join('.', 'not-exists'), | ||
])('detects new and deleted symlink to %s', async target => { | ||
const newLink = join(appRoot, 'newlink'); | ||
const relativePath = join('app', 'newlink'); | ||
expect(await nextEvent(() => symlink(target, newLink))).toStrictEqual({ | ||
path: relativePath, | ||
eventType: 'add', | ||
stat: expect.any(Object), | ||
}); | ||
expect(await nextEvent(() => unlink(newLink))).toStrictEqual({ | ||
path: relativePath, | ||
eventType: 'delete', | ||
stat: undefined, | ||
}); | ||
}); | ||
|
||
maybeTest( | ||
'emits deletion for all files when a directory is deleted', | ||
async () => { | ||
await allEvents( | ||
async () => { | ||
await mkdir(join(appRoot, 'subdir', 'subdir2'), {recursive: true}); | ||
await Promise.all([ | ||
writeFile(join(appRoot, 'subdir', 'deep.js'), ''), | ||
writeFile(join(appRoot, 'subdir', 'subdir2', 'deeper.js'), ''), | ||
]); | ||
}, | ||
[ | ||
[join('app', 'subdir'), 'add'], | ||
[join('app', 'subdir', 'subdir2'), 'add'], | ||
[join('app', 'subdir', 'deep.js'), 'add'], | ||
[join('app', 'subdir', 'subdir2', 'deeper.js'), 'add'], | ||
], | ||
{ | ||
// FIXME: NodeWatcher may report events multiple times as it | ||
// establishes watches on new directories and then crawls them | ||
// recursively, emitting all contents. When a directory is created | ||
// then immediately populated, the new contents may be seen by both | ||
// the crawl and the watch. | ||
rejectUnexpected: !(watcherInstance instanceof NodeWatcher), | ||
}, | ||
); | ||
|
||
// FIXME: Because NodeWatcher recursively watches new subtrees and | ||
// watch initialization is not instantaneous, we need to allow some | ||
// time for NodeWatcher to watch the new directories, othwerwise | ||
// deletion events may be missed. | ||
if (watcherInstance instanceof NodeWatcher) { | ||
await new Promise(resolve => setTimeout(resolve, 200)); | ||
} | ||
|
||
await allEvents( | ||
async () => { | ||
await rm(join(appRoot, 'subdir'), {recursive: true}); | ||
}, | ||
[ | ||
[join('app', 'subdir'), 'delete'], | ||
[join('app', 'subdir', 'subdir2'), 'delete'], | ||
[join('app', 'subdir', 'deep.js'), 'delete'], | ||
[join('app', 'subdir', 'subdir2', 'deeper.js'), 'delete'], | ||
], | ||
{rejectUnexpected: true}, | ||
); | ||
}, | ||
); | ||
}, | ||
); |