Skip to content

Commit

Permalink
Add Watcher tests for behaviour on pre-existing files
Browse files Browse the repository at this point in the history
Summary:
Refactor watcher integration tests to extract a lot of supporting code for clarity, and add some fixtures *before* creating the Watcher in order to test behaviour when pre-existing files are modified or deleted. Use those fixtures for two additional tests.

(Motivation: Similar tests for symlinks have inconsistent results, fixed in the next diff)

Changelog: [Internal]

Reviewed By: jacdebug

Differential Revision: D42110285

fbshipit-source-id: 0721cbc5a41ce3eddef7ff4c4aff0b7ffff34156
  • Loading branch information
robhogan authored and facebook-github-bot committed Dec 19, 2022
1 parent 339794e commit 33ead26
Show file tree
Hide file tree
Showing 2 changed files with 266 additions and 148 deletions.
185 changes: 185 additions & 0 deletions packages/metro-file-map/src/watchers/__tests__/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* 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 NodeWatcher from '../NodeWatcher';
import FSEventsWatcher from '../FSEventsWatcher';
import WatchmanWatcher from '../WatchmanWatcher';
import type {ChangeEventMetadata} from '../../flow-types';
import {promises as fsPromises} from 'fs';
import {execSync} from 'child_process';
import {join} from 'path';
import os from 'os';
import invariant from 'invariant';

jest.useRealTimers();

const {mkdtemp, writeFile} = fsPromises;

// 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.
export const WATCHERS: $ReadOnly<{
[key: string]:
| Class<NodeWatcher>
| Class<FSEventsWatcher>
| Class<WatchmanWatcher>
| null,
}> = {
Node: NodeWatcher,
Watchman: isWatchmanOnPath() ? WatchmanWatcher : null,
FSEvents: FSEventsWatcher.isSupported() ? FSEventsWatcher : null,
};

export type EventHelpers = {
nextEvent: (afterFn: () => Promise<void>) => Promise<{
eventType: string,
path: string,
metadata?: ChangeEventMetadata,
}>,
untilEvent: (
afterFn: () => Promise<void>,
expectedPath: string,
expectedEvent: 'add' | 'delete' | 'change',
) => Promise<void>,
allEvents: (
afterFn: () => Promise<void>,
events: $ReadOnlyArray<[string, 'add' | 'delete' | 'change']>,
opts?: {rejectUnexpected: boolean},
) => Promise<void>,
};

export const createTempWatchRoot = async (
watcherName: string,
watchmanConfig: {[key: string]: mixed} | false = {},
): Promise<string> => {
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.
const watchRoot = await fsPromises.realpath(tmpDir);
if (watchmanConfig) {
await writeFile(
join(watchRoot, '.watchmanconfig'),
JSON.stringify(watchmanConfig),
);
}

return watchRoot;
};

export const startWatching = async (
watcherName: string,
watchRoot: string,
opts: WatcherOptions,
): (Promise<{
eventHelpers: EventHelpers,
stopWatching: () => Promise<void>,
}>) => {
const Watcher = WATCHERS[watcherName];
invariant(Watcher != null, `Watcher ${watcherName} is not supported`);
const watcherInstance = new Watcher(watchRoot, opts);

await new Promise(resolve => {
watcherInstance.once('ready', resolve);
});

const eventHelpers: EventHelpers = {
nextEvent: afterFn =>
Promise.all([
new Promise<{
eventType: string,
metadata?: ChangeEventMetadata,
path: string,
}>((resolve, reject) => {
const listener = (
eventType: string,
path: string,
root: string,
metadata?: ChangeEventMetadata,
) => {
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, metadata});
};
watcherInstance.on('all', listener);
}),
afterFn(),
]).then(([event]) => event),

untilEvent: (afterFn, expectedPath, expectedEventType) =>
eventHelpers.allEvents(afterFn, [[expectedPath, expectedEventType]], {
rejectUnexpected: false,
}),

// $FlowFixMe[incompatible-use]
allEvents: (afterFn, expectedEvents, {rejectUnexpected = true} = {}) =>
Promise.all([
new Promise((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(() => {}),
};

return {
eventHelpers,
stopWatching: async () => {
await watcherInstance.close();
},
};
};
Loading

0 comments on commit 33ead26

Please sign in to comment.