From 6f8de6ab807f020ba8e245abd953fb7fd0a39d4c Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 18 Jan 2023 11:38:45 -0500 Subject: [PATCH] Starting attempt to move kubeconfig sync tests to application builder - Blocked on catalog sync not being injectable yet Signed-off-by: Sebastian Malton --- .../watch.global-override-for-injectable.ts | 2 +- src/common/fs/{watch => }/watch.injectable.ts | 2 +- .../extension-discovery.injectable.ts | 2 +- .../extension-discovery.test.ts | 2 +- .../extension-discovery.ts | 2 +- .../reactive-catalog.test.tsx.snap | 882 ++++++++++++++++++ .../kubeconfig-sync/reactive-catalog.test.tsx | 80 ++ .../__test__/kubeconfig-sync.test.ts | 4 +- .../compute-diff.injectable.ts | 2 +- .../create-watcher.injectable.ts | 66 ++ .../watch-file-changes.injectable.ts | 60 +- src/renderer/components/+catalog/catalog.tsx | 1 + .../user-templates.injectable.ts | 2 +- .../test-utils/get-application-builder.tsx | 2 +- src/test-utils/override-fs-with-fakes.ts | 76 +- 15 files changed, 1141 insertions(+), 44 deletions(-) rename src/common/fs/{watch => }/watch.global-override-for-injectable.ts (82%) rename src/common/fs/{watch => }/watch.injectable.ts (99%) create mode 100644 src/features/kubeconfig-sync/__snapshots__/reactive-catalog.test.tsx.snap create mode 100644 src/features/kubeconfig-sync/reactive-catalog.test.tsx create mode 100644 src/main/catalog-sources/kubeconfig-sync/create-watcher.injectable.ts diff --git a/src/common/fs/watch/watch.global-override-for-injectable.ts b/src/common/fs/watch.global-override-for-injectable.ts similarity index 82% rename from src/common/fs/watch/watch.global-override-for-injectable.ts rename to src/common/fs/watch.global-override-for-injectable.ts index 689c7150cf..c99f2c3916 100644 --- a/src/common/fs/watch/watch.global-override-for-injectable.ts +++ b/src/common/fs/watch.global-override-for-injectable.ts @@ -2,7 +2,7 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { getGlobalOverride } from "../../test-utils/get-global-override"; +import { getGlobalOverride } from "../test-utils/get-global-override"; import watchInjectable from "./watch.injectable"; export default getGlobalOverride(watchInjectable, () => () => { diff --git a/src/common/fs/watch/watch.injectable.ts b/src/common/fs/watch.injectable.ts similarity index 99% rename from src/common/fs/watch/watch.injectable.ts rename to src/common/fs/watch.injectable.ts index 50f96cdf57..832af07b01 100644 --- a/src/common/fs/watch/watch.injectable.ts +++ b/src/common/fs/watch.injectable.ts @@ -6,7 +6,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import { watch } from "chokidar"; import type { Stats } from "fs"; import type TypedEventEmitter from "typed-emitter"; -import type { SingleOrMany } from "../../utils"; +import type { SingleOrMany } from "../utils"; export interface AlwaysStatWatcherEvents { add: (path: string, stats: Stats) => void; diff --git a/src/extensions/extension-discovery/extension-discovery.injectable.ts b/src/extensions/extension-discovery/extension-discovery.injectable.ts index 378f519bb7..62679d4100 100644 --- a/src/extensions/extension-discovery/extension-discovery.injectable.ts +++ b/src/extensions/extension-discovery/extension-discovery.injectable.ts @@ -13,7 +13,7 @@ import extensionPackageRootDirectoryInjectable from "../extension-installer/exte import readJsonFileInjectable from "../../common/fs/read-json-file.injectable"; import loggerInjectable from "../../common/logger.injectable"; import pathExistsInjectable from "../../common/fs/path-exists.injectable"; -import watchInjectable from "../../common/fs/watch/watch.injectable"; +import watchInjectable from "../../common/fs/watch.injectable"; import accessPathInjectable from "../../common/fs/access-path.injectable"; import copyInjectable from "../../common/fs/copy.injectable"; import ensureDirInjectable from "../../common/fs/ensure-dir.injectable"; diff --git a/src/extensions/extension-discovery/extension-discovery.test.ts b/src/extensions/extension-discovery/extension-discovery.test.ts index d71f8c5292..91f0be84e2 100644 --- a/src/extensions/extension-discovery/extension-discovery.test.ts +++ b/src/extensions/extension-discovery/extension-discovery.test.ts @@ -13,7 +13,7 @@ import { delay } from "../../renderer/utils"; import { observable, runInAction, when } from "mobx"; import readJsonFileInjectable from "../../common/fs/read-json-file.injectable"; import pathExistsInjectable from "../../common/fs/path-exists.injectable"; -import watchInjectable from "../../common/fs/watch/watch.injectable"; +import watchInjectable from "../../common/fs/watch.injectable"; import extensionApiVersionInjectable from "../../common/vars/extension-api-version.injectable"; import removePathInjectable from "../../common/fs/remove.injectable"; import type { JoinPaths } from "../../common/path/join-paths.injectable"; diff --git a/src/extensions/extension-discovery/extension-discovery.ts b/src/extensions/extension-discovery/extension-discovery.ts index c9646a1c6c..760595fb1c 100644 --- a/src/extensions/extension-discovery/extension-discovery.ts +++ b/src/extensions/extension-discovery/extension-discovery.ts @@ -17,7 +17,7 @@ import { requestInitialExtensionDiscovery } from "../../renderer/ipc"; import type { ReadJson } from "../../common/fs/read-json-file.injectable"; import type { Logger } from "../../common/logger"; import type { PathExists } from "../../common/fs/path-exists.injectable"; -import type { Watch } from "../../common/fs/watch/watch.injectable"; +import type { Watch } from "../../common/fs/watch.injectable"; import type { Stats } from "fs"; import type { LStat } from "../../common/fs/lstat.injectable"; import type { ReadDirectory } from "../../common/fs/read-directory.injectable"; diff --git a/src/features/kubeconfig-sync/__snapshots__/reactive-catalog.test.tsx.snap b/src/features/kubeconfig-sync/__snapshots__/reactive-catalog.test.tsx.snap new file mode 100644 index 0000000000..087bc77c28 --- /dev/null +++ b/src/features/kubeconfig-sync/__snapshots__/reactive-catalog.test.tsx.snap @@ -0,0 +1,882 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`kubeconfig sync showing reactive catalog renders 1`] = ` + +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+
+ +
+
+

+ Welcome to some-product-name! +

+

+ To get you started we have auto-detected your clusters in your + + kubeconfig file and added them to the catalog, your centralized + + view for managing all your cloud-native resources. +
+
+ If you have any questions or feedback, please join our + + Lens Community slack channel + + . +

+ +
+
+
+
+
+
+
+
+
+
+
+ Ca +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 1 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+ +`; + +exports[`kubeconfig sync showing reactive catalog when navigating to the catalog renders 1`] = ` + +
+
+
+
+
+ + + home + + +
+
+
+ + + arrow_back + + +
+
+
+ + + arrow_forward + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ca +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + arrow_left + + +
+
+ 1 +
+
+ + + arrow_right + + +
+
+
+
+
+
+
+
+
+ +`; diff --git a/src/features/kubeconfig-sync/reactive-catalog.test.tsx b/src/features/kubeconfig-sync/reactive-catalog.test.tsx new file mode 100644 index 0000000000..7a50e922c4 --- /dev/null +++ b/src/features/kubeconfig-sync/reactive-catalog.test.tsx @@ -0,0 +1,80 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DiContainer } from "@ogre-tools/injectable"; +import type { RenderResult } from "@testing-library/react"; +import navigateToCatalogInjectable from "../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable"; +import writeFileInjectable from "../../common/fs/write-file.injectable"; +import { dumpConfigYaml } from "../../common/kube-helpers"; +import homeDirectoryPathInjectable from "../../common/os/home-directory-path.injectable"; +import joinPathsInjectable from "../../common/path/join-paths.injectable"; +import { flushPromises } from "../../common/test-utils/flush-promises"; +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; + +describe("kubeconfig sync showing reactive catalog", () => { + let builder: ApplicationBuilder; + let rendered: RenderResult; + let windowDi: DiContainer; + let mainDi: DiContainer; + + beforeEach(async () => { + builder = getApplicationBuilder(); + + // builder.mainDi.override(loggerInjectable, () => console as any); + rendered = await builder.render(); + windowDi = builder.applicationWindow.only.di; + mainDi = builder.mainDi; + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + describe("when navigating to the catalog", () => { + beforeEach(() => { + const navigateToCatalog = windowDi.inject(navigateToCatalogInjectable); + + navigateToCatalog(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + describe("when a config file is written under ~/.kube", () => { + beforeEach(async () => { + const writeFile = mainDi.inject(writeFileInjectable); + const joinPaths = mainDi.inject(joinPathsInjectable); + const homeDirectoryPath = mainDi.inject(homeDirectoryPathInjectable); + + const configContents = dumpConfigYaml({ + clusters: [{ + name: "some-cluster-name", + server: "https://1.2.3.4", + skipTLSVerify: false, + }], + users: [{ + name: "some-user-name", + }], + contexts: [{ + cluster: "some-cluster-name", + name: "some-context-name", + user: "some-user-name", + }], + }); + + await writeFile(joinPaths(homeDirectoryPath, ".kube", "config"), configContents); + await flushPromises(); + }); + + it.only("eventually shows the cluster as a new entity", async () => { + await rendered.findByTestId("catalog-entity-row-for-some-cluster-name", undefined, { + timeout: 10_000, + }); + }, 100_000); + }); + }); +}); diff --git a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts index 6f3a8e3e0b..82e6a0e576 100644 --- a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts +++ b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts @@ -25,8 +25,8 @@ import type { AsyncFnMock } from "@async-fn/jest"; import type { Stat } from "../../../common/fs/stat.injectable"; import asyncFn from "@async-fn/jest"; import statInjectable from "../../../common/fs/stat.injectable"; -import type { Watcher } from "../../../common/fs/watch/watch.injectable"; -import watchInjectable from "../../../common/fs/watch/watch.injectable"; +import type { Watcher } from "../../../common/fs/watch.injectable"; +import watchInjectable from "../../../common/fs/watch.injectable"; import EventEmitter from "events"; import type { ReadStream, Stats } from "fs"; import createReadFileStreamInjectable from "../../../common/fs/create-read-file-stream.injectable"; diff --git a/src/main/catalog-sources/kubeconfig-sync/compute-diff.injectable.ts b/src/main/catalog-sources/kubeconfig-sync/compute-diff.injectable.ts index cb1e62e6e8..23ed253663 100644 --- a/src/main/catalog-sources/kubeconfig-sync/compute-diff.injectable.ts +++ b/src/main/catalog-sources/kubeconfig-sync/compute-diff.injectable.ts @@ -86,7 +86,7 @@ const computeKubeconfigDiffInjectable = getInjectable({ logger.debug(`Added new cluster from sync`, { filePath, contextName }); } catch (error) { - logger.warn(`Failed to create cluster from model: ${error}`, { filePath, contextName }); + logger.warn(`Failed to create cluster with context="${contextName}" from path="${filePath}"`, error); } } } catch (error) { diff --git a/src/main/catalog-sources/kubeconfig-sync/create-watcher.injectable.ts b/src/main/catalog-sources/kubeconfig-sync/create-watcher.injectable.ts new file mode 100644 index 0000000000..3954e7f973 --- /dev/null +++ b/src/main/catalog-sources/kubeconfig-sync/create-watcher.injectable.ts @@ -0,0 +1,66 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { Stats } from "fs"; +import watchInjectable from "../../../common/fs/watch.injectable"; +import loggerInjectable from "../../../common/logger.injectable"; + +export interface CreateKubeSyncWatcherOptions { + isDirectorySync: boolean; + onChange: (filePath: string, stats: Stats) => void; + onAdd: (filePath: string, stats: Stats) => void; + onRemove: (filePath: string) => void; + onError: (error: Error) => void; +} + +export interface KubeSyncWatcher { + stop: () => void; +} + +export type CreateKubeSyncWatcher = (filePath: string, opts: CreateKubeSyncWatcherOptions) => KubeSyncWatcher; + +const createKubeSyncWatcherInjectable = getInjectable({ + id: "create-kube-sync-watcher", + instantiate: (di): CreateKubeSyncWatcher => { + const watch = di.inject(watchInjectable); + const logger = di.inject(loggerInjectable); + + return (filePath, { isDirectorySync, ...handlers }) => { + const watcher = watch(filePath, { + followSymlinks: true, + depth: isDirectorySync ? 0 : 1, // DIRs works with 0 but files need 1 (bug: https://github.com/paulmillr/chokidar/issues/1095) + disableGlobbing: true, + ignorePermissionErrors: true, + usePolling: false, + awaitWriteFinish: { + pollInterval: 100, + stabilityThreshold: 1000, + }, + atomic: 150, // for "atomic writes" + alwaysStat: true, + }); + + watcher + .on("change", handlers.onChange) + .on("add", handlers.onAdd) + .on("unlink", handlers.onRemove) + .on("error", handlers.onError); + + return { + stop: () => { + void (async () => { + try { + await watcher.close(); + } catch (error) { + logger.warn(`[KUBE-SYNC-WATCHER]: failed to stop watching "${filePath}": ${error}`); + } + })(); + }, + }; + }; + }, +}); + +export default createKubeSyncWatcherInjectable; diff --git a/src/main/catalog-sources/kubeconfig-sync/watch-file-changes.injectable.ts b/src/main/catalog-sources/kubeconfig-sync/watch-file-changes.injectable.ts index 244daeec69..6cdfa78805 100644 --- a/src/main/catalog-sources/kubeconfig-sync/watch-file-changes.injectable.ts +++ b/src/main/catalog-sources/kubeconfig-sync/watch-file-changes.injectable.ts @@ -11,8 +11,8 @@ import { inspect } from "util"; import type { CatalogEntity } from "../../../common/catalog"; import type { Cluster } from "../../../common/cluster/cluster"; import statInjectable from "../../../common/fs/stat.injectable"; -import type { Watcher } from "../../../common/fs/watch/watch.injectable"; -import watchInjectable from "../../../common/fs/watch/watch.injectable"; +import type { KubeSyncWatcher } from "./create-watcher.injectable"; +import createKubeSyncWatcherInjectable from "./create-watcher.injectable"; import type { Disposer } from "../../../common/utils"; import { getOrInsertWith, iter } from "../../../common/utils"; import diffChangedKubeconfigInjectable from "./diff-changed-kubeconfig.injectable"; @@ -38,8 +38,8 @@ const ignoreGlobs = [ * Even if you have a cert-file, key-file, and client-cert files that is only * 12kb of extra data (at 4096 bytes each) which allows for around 150 entries. */ -const folderSyncMaxAllowedFileReadSize = 2 * 1024 * 1024; // 2 MiB -const fileSyncMaxAllowedFileReadSize = 16 * folderSyncMaxAllowedFileReadSize; // 32 MiB +const dirSyncMaxAllowedFileReadSize = 2 * 1024 * 1024; // 2 MiB +const fileSyncMaxAllowedFileReadSize = 16 * dirSyncMaxAllowedFileReadSize; // 32 MiB const watchKubeconfigFileChangesInjectable = getInjectable({ id: "watch-kubeconfig-file-changes", @@ -47,39 +47,27 @@ const watchKubeconfigFileChangesInjectable = getInjectable({ const diffChangedKubeconfig = di.inject(diffChangedKubeconfigInjectable); const logger = di.inject(kubeconfigSyncLoggerInjectable); const stat = di.inject(statInjectable); - const watch = di.inject(watchInjectable); + const createKubeSyncWatcher = di.inject(createKubeSyncWatcherInjectable); return (filePath) => { const rootSource = observable.map>(); const derivedSource = computed(() => Array.from(iter.flatMap(rootSource.values(), from => iter.map(from.values(), child => child[1])))); - let watcher: Watcher; + let watcher: KubeSyncWatcher; (async () => { try { const stats = await stat(filePath); - const isFolderSync = stats.isDirectory(); + const isDirectorySync = stats.isDirectory(); const cleanupFns = new Map(); - const maxAllowedFileReadSize = isFolderSync - ? folderSyncMaxAllowedFileReadSize + const maxAllowedFileReadSize = isDirectorySync + ? dirSyncMaxAllowedFileReadSize : fileSyncMaxAllowedFileReadSize; - watcher = watch(filePath, { - followSymlinks: true, - depth: isFolderSync ? 0 : 1, // DIRs works with 0 but files need 1 (bug: https://github.com/paulmillr/chokidar/issues/1095) - disableGlobbing: true, - ignorePermissionErrors: true, - usePolling: false, - awaitWriteFinish: { - pollInterval: 100, - stabilityThreshold: 1000, - }, - atomic: 150, // for "atomic writes" - alwaysStat: true, - }); - - watcher - .on("change", (childFilePath, stats): void => { + watcher = createKubeSyncWatcher(filePath, { + isDirectorySync, + onChange: (childFilePath, stats): void => { + console.log("change", childFilePath); const cleanup = cleanupFns.get(childFilePath); if (!cleanup) { @@ -94,9 +82,11 @@ const watchKubeconfigFileChangesInjectable = getInjectable({ stats, maxAllowedFileReadSize, })); - }) - .on("add", (childFilePath, stats): void => { - if (isFolderSync) { + }, + onAdd: (childFilePath, stats): void => { + console.log("add", childFilePath); + + if (isDirectorySync) { const fileName = path.basename(childFilePath); for (const ignoreGlob of ignoreGlobs) { @@ -112,20 +102,24 @@ const watchKubeconfigFileChangesInjectable = getInjectable({ stats, maxAllowedFileReadSize, })); - }) - .on("unlink", (childFilePath) => { + }, + onRemove: (childFilePath) => { cleanupFns.get(childFilePath)?.(); cleanupFns.delete(childFilePath); rootSource.delete(childFilePath); - }) - .on("error", error => logger.error(`watching file/folder failed: ${error}`, { filePath })); + }, + onError: (error) => { + console.log("error", error); + logger.error(`watching file/folder failed: ${error}`, { filePath }); + }, + }); } catch (error) { logger.warn(`failed to start watching changes: ${error}`); } })(); return [derivedSource, () => { - watcher?.close(); + watcher?.stop(); }]; }; }, diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index acd5346b17..6811c4df40 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -307,6 +307,7 @@ class NonInjectedCatalog extends React.Component { getItems={() => catalogEntityStore.entities.get()} customizeTableRowProps={entity => ({ disabled: !entity.isEnabled(), + testId: `catalog-entity-row-for-${entity.getId()}`, })} {...getCategoryColumns({ activeCategory })} onDetails={this.onDetails} diff --git a/src/renderer/components/dock/create-resource/user-templates.injectable.ts b/src/renderer/components/dock/create-resource/user-templates.injectable.ts index 65b896f95d..0cc36ea687 100644 --- a/src/renderer/components/dock/create-resource/user-templates.injectable.ts +++ b/src/renderer/components/dock/create-resource/user-templates.injectable.ts @@ -9,7 +9,7 @@ import { readFile } from "fs/promises"; import { hasCorrectExtension } from "./has-correct-extension"; import type { RawTemplates } from "./create-resource-templates.injectable"; import joinPathsInjectable from "../../../../common/path/join-paths.injectable"; -import watchInjectable from "../../../../common/fs/watch/watch.injectable"; +import watchInjectable from "../../../../common/fs/watch.injectable"; import getRelativePathInjectable from "../../../../common/path/get-relative-path.injectable"; import homeDirectoryPathInjectable from "../../../../common/os/home-directory-path.injectable"; import getDirnameOfPathInjectable from "../../../../common/path/get-dirname.injectable"; diff --git a/src/renderer/components/test-utils/get-application-builder.tsx b/src/renderer/components/test-utils/get-application-builder.tsx index 43f0bdeea5..e7381cdc4e 100644 --- a/src/renderer/components/test-utils/get-application-builder.tsx +++ b/src/renderer/components/test-utils/get-application-builder.tsx @@ -192,7 +192,7 @@ export const getApplicationBuilder = ({ useFakeTime = true }: ApplicationBuilder const overrideFsWithFakes = getOverrideFsWithFakes(); - overrideFsWithFakes(mainDi); + overrideFsWithFakes(mainDi, true); // Set up ~/.kube as existing as a folder { diff --git a/src/test-utils/override-fs-with-fakes.ts b/src/test-utils/override-fs-with-fakes.ts index 734170b0c7..0731ec6197 100644 --- a/src/test-utils/override-fs-with-fakes.ts +++ b/src/test-utils/override-fs-with-fakes.ts @@ -10,6 +10,9 @@ import type { readJsonSync as readJsonSyncImpl, writeJsonSync as writeJsonSyncImpl, } from "fs-extra"; +import createKubeSyncWatcherInjectable from "../main/catalog-sources/kubeconfig-sync/create-watcher.injectable"; +import { isErrnoException } from "../common/utils"; +import joinPathsInjectable from "../common/path/join-paths.injectable"; export const getOverrideFsWithFakes = () => { const root = createFsFromVolume(Volume.fromJSON({})); @@ -41,7 +44,7 @@ export const getOverrideFsWithFakes = () => { root.mkdirpSync(path, mode); }) as typeof ensureDirSyncImpl; - return (di: DiContainer) => { + return (di: DiContainer, overrideWatches = false) => { di.override(fsInjectable, () => ({ pathExists: async (path) => root.existsSync(path), pathExistsSync: root.existsSync, @@ -63,5 +66,76 @@ export const getOverrideFsWithFakes = () => { createReadStream: root.createReadStream as any, stat: root.promises.stat as any, })); + + if (overrideWatches) { + di.override(createKubeSyncWatcherInjectable, (di) => { + const joinPaths = di.inject(joinPathsInjectable); + + return ((path, options) => { + const watcher = root.watch( path, { + recursive: options.isDirectorySync, + }); + const seenPaths = new Set(); + + console.log("watching", path); + + watcher.addListener("rename", (eventType, filename: string) => { + try { + const stats = root.statSync(filename); + + options.onAdd(filename, stats); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") { + options.onRemove(filename); + } else { + options.onError(error as Error); + } + } + }); + watcher.addListener("change", (...args) => { + const [,filename] = args; + + if (options.isDirectorySync) { + // For testing purposes just emit change events for all files + for (const entry of root.readdirSync(filename) as string[]) { + const path = joinPaths(filename, entry); + + try { + const stats = root.statSync(path); + + if (seenPaths.has(path)) { + options.onChange(path, stats); + } else { + seenPaths.add(path); + options.onAdd(path, stats); + } + } catch (error) { + options.onError(error as Error); + } + } + } else { + try { + const stats = root.statSync(filename); + + if (seenPaths.has(filename)) { + options.onChange(filename, stats); + } else { + seenPaths.add(filename); + options.onAdd(filename, stats); + } + } catch (error) { + options.onError(error as Error); + } + } + }); + + return { + stop: () => { + watcher.close(); + }, + }; + }); + }); + } }; };