From f6a99c2f9602c002ba82a9bd78e3a72b50cbe81c Mon Sep 17 00:00:00 2001 From: Jon Edvald Date: Wed, 9 Jan 2019 20:30:16 +0100 Subject: [PATCH] refactor: use events for file watching instead of callbacks I started this as part of another branch, but figured I'd split this into a new PR. This should have no functional differences to the prior implementation, apart from exposing events that can be used in more places. Added tests for the Watcher class along the way. --- .circleci/config.yml | 2 + garden-service/package-lock.json | 53 +++++ garden-service/package.json | 4 + garden-service/src/cli/cli.ts | 3 + garden-service/src/events.ts | 20 ++ garden-service/src/garden.ts | 23 +++ garden-service/src/process.ts | 65 +++--- garden-service/src/util/util.ts | 6 +- garden-service/src/watch.ts | 194 ++++++++++-------- .../test/data/test-project-watch/garden.yml | 11 + .../data/test-project-watch/module-a/foo.txt | 1 + .../test-project-watch/module-a/garden.yml | 13 ++ .../module-a/subdir/foo2.txt | 0 garden-service/test/helpers.ts | 9 +- garden-service/test/setup.ts | 7 +- garden-service/test/src/watch.ts | 124 +++++++++++ 16 files changed, 414 insertions(+), 121 deletions(-) create mode 100644 garden-service/test/data/test-project-watch/garden.yml create mode 100644 garden-service/test/data/test-project-watch/module-a/foo.txt create mode 100644 garden-service/test/data/test-project-watch/module-a/garden.yml create mode 100644 garden-service/test/data/test-project-watch/module-a/subdir/foo2.txt create mode 100644 garden-service/test/src/watch.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index dba9078acb..b9c0140675 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -248,6 +248,8 @@ jobs: name: test command: | npm run ci-test + environment: + CHOKIDAR_USEPOLLING: "1" release-service-npm: <<: *node-config steps: diff --git a/garden-service/package-lock.json b/garden-service/package-lock.json index e83e20e57f..cc7ea57d90 100644 --- a/garden-service/package-lock.json +++ b/garden-service/package-lock.json @@ -793,6 +793,12 @@ "integrity": "sha512-l3Jn4S6930TqfjYXHdvAhHlVrCbT5gYrka8HoDAzJfiBp8tz3Q41pMK5pJg/2qd1MNMZ2n/W8S+Hr+a2lvcMOA==", "dev": true }, + "@types/p-event": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@types/p-event/-/p-event-1.3.0.tgz", + "integrity": "sha512-Pp6w4bQdrAiIzi5JnO6AVh6Vq51RjD27DxyeKHqCgPrlfqYu3xPnCs3pdqCVIokEIVX7EbJMIGG0ctzFk5u9lA==", + "dev": true + }, "@types/p-queue": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/p-queue/-/p-queue-3.0.0.tgz", @@ -1000,6 +1006,15 @@ "@types/node": "*" } }, + "@types/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-6qjr7Erk0b9FkZSu17wUJaWhmzUgGfQuec7eIwl9cP7V/YO/k9IcMnHSwAjxAeadC5guq9rwHcoij7PT1RkO1w==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/undertaker": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@types/undertaker/-/undertaker-1.2.0.tgz", @@ -8867,6 +8882,15 @@ "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" }, + "p-event": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-2.1.0.tgz", + "integrity": "sha512-sDEpDVnzLGlJj3k590uUdpfEUySP5yAYlvfTCu5hTDvSTXQVecYWKcEwdO49PrZlnJ5wkfAvtawnno/jyXeqvA==", + "dev": true, + "requires": { + "p-timeout": "^2.0.1" + } + }, "p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -8893,6 +8917,15 @@ "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-3.0.0.tgz", "integrity": "sha512-2tv/MRmPXfmfnjLLJAHl+DdU8p2DhZafAnlpm/C/T5BpF5L9wKz5tMin4A4N2zVpJL2YMhPlRmtO7s5EtNrjfA==" }, + "p-timeout": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-2.0.1.tgz", + "integrity": "sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==", + "dev": true, + "requires": { + "p-finally": "^1.0.0" + } + }, "p-try": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", @@ -11241,6 +11274,26 @@ "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", "integrity": "sha1-riF2gXXRVZ1IvvNUILL0li8JwzA=" }, + "touch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", + "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, + "requires": { + "nopt": "~1.0.10" + }, + "dependencies": { + "nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=", + "dev": true, + "requires": { + "abbrev": "1" + } + } + } + }, "tough-cookie": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", diff --git a/garden-service/package.json b/garden-service/package.json index 81e526704c..06cea8274c 100644 --- a/garden-service/package.json +++ b/garden-service/package.json @@ -117,12 +117,14 @@ "@types/node": "^10.12.15", "@types/node-emoji": "^1.8.0", "@types/normalize-url": "^3.3.0", + "@types/p-event": "^1.3.0", "@types/p-queue": "^3.0.0", "@types/path-is-inside": "^1.0.0", "@types/prettyjson": "0.0.28", "@types/string-width": "^2.0.0", "@types/supertest": "^2.0.7", "@types/tar": "^4.0.0", + "@types/touch": "^3.1.1", "@types/uniqid": "^4.1.2", "@types/unzip": "^0.1.1", "@types/unzipper": "^0.9.1", @@ -140,6 +142,7 @@ "nock": "^10.0.4", "nodetree": "0.0.3", "nyc": "^13.1.0", + "p-event": "^2.1.0", "pegjs": "^0.10.0", "shx": "^0.3.2", "snyk": "^1.117.2", @@ -147,6 +150,7 @@ "testdouble-chai": "^0.5.0", "timekeeper": "^2.1.2", "tmp-promise": "^1.0.5", + "touch": "^3.1.0", "ts-node": "^7.0.1", "tslint": "^5.12.0", "tslint-microsoft-contrib": "^6.0.0", diff --git a/garden-service/src/cli/cli.ts b/garden-service/src/cli/cli.ts index c81a59f09e..1e2ecf242c 100644 --- a/garden-service/src/cli/cli.ts +++ b/garden-service/src/cli/cli.ts @@ -296,6 +296,9 @@ export class GardenCli { args: parsedArgs, opts: parsedOpts, }) + + await garden.close() + } while (result.restartRequired) // We attach the action result to cli context so that we can process it in the parse method diff --git a/garden-service/src/events.ts b/garden-service/src/events.ts index 451987d512..7d9178b0d0 100644 --- a/garden-service/src/events.ts +++ b/garden-service/src/events.ts @@ -48,7 +48,27 @@ export class EventBus extends EventEmitter2 { * The supported events and their interfaces. */ export interface Events { + // Internal test/control events + _restart: string, _test: string, + + // Watcher events + configAdded: { + path: string, + }, + projectConfigChanged: {}, + moduleConfigChanged: { + name: string, + }, + moduleSourcesChanged: { + name: string, + pathChanged: string, + }, + moduleRemoved: { + name: string, + }, + + // TaskGraph events taskPending: { addedAt: Date, key: string, diff --git a/garden-service/src/garden.ts b/garden-service/src/garden.ts index 27a3c7dc7e..cf1478a89a 100644 --- a/garden-service/src/garden.ts +++ b/garden-service/src/garden.ts @@ -52,6 +52,7 @@ import { pickKeys, throwOnMissingNames, uniqByName, + Ignorer, } from "./util/util" import { DEFAULT_NAMESPACE, @@ -108,6 +109,7 @@ import { SUPPORTED_PLATFORMS, SupportedPlatform } from "./constants" import { platform, arch } from "os" import { LogEntry } from "./logger/log-entry" import { EventBus } from "./events" +import { Watcher } from "./watch" export interface ActionHandlerMap { [actionName: string]: PluginActions[T] @@ -154,6 +156,7 @@ export class Garden { private readonly taskNameIndex: { [key: string]: string } // task name -> module name private readonly hotReloadScheduler: HotReloadScheduler private readonly taskGraph: TaskGraph + private readonly watcher: Watcher public readonly environment: Environment public readonly localConfigStore: LocalConfigStore @@ -169,6 +172,7 @@ export class Garden { variables: PrimitiveMap, public readonly projectSources: SourceConfig[] = [], public readonly buildDir: BuildDir, + public readonly ignorer: Ignorer, public readonly opts: GardenOpts, ) { // make sure we're on a supported platform @@ -209,6 +213,7 @@ export class Garden { this.actions = new ActionHelper(this) this.hotReloadScheduler = new HotReloadScheduler() this.events = new EventBus() + this.watcher = new Watcher(this, this.log) } static async factory( @@ -293,6 +298,7 @@ export class Garden { const variables = merge({}, environmentDefaults.variables, environmentConfig.variables) const buildDir = await BuildDir.factory(projectRoot) + const ignorer = await getIgnorer(projectRoot) const garden = new this( projectRoot, @@ -301,6 +307,7 @@ export class Garden { variables, projectSources, buildDir, + ignorer, opts, ) as InstanceType @@ -319,6 +326,13 @@ export class Garden { return garden } + /** + * Clean up before shutting down. + */ + async close() { + this.watcher.stop() + } + getPluginContext(providerName: string) { return createPluginContext(this, providerName) } @@ -339,6 +353,15 @@ export class Garden { return this.hotReloadScheduler.requestHotReload(moduleName, hotReloadHandler) } + /** + * Enables the file watcher for the project. + * Make sure to stop it using `.close()` when cleaning up or when watching is no longer needed. + */ + async startWatcher() { + const modules = await this.getModules() + this.watcher.start(modules) + } + private registerPlugin(name: string, moduleOrFactory: RegisterPluginParam) { let factory: PluginFactory diff --git a/garden-service/src/process.ts b/garden-service/src/process.ts index 91bc65396c..88e37d1876 100644 --- a/garden-service/src/process.ts +++ b/garden-service/src/process.ts @@ -8,14 +8,12 @@ import Bluebird = require("bluebird") import chalk from "chalk" -import { padEnd } from "lodash" +import { padEnd, keyBy } from "lodash" import { Module } from "./types/module" import { Service } from "./types/service" import { BaseTask } from "./tasks/base" import { TaskResults } from "./task-graph" -import { FSWatcher } from "./watch" -import { registerCleanupFunction } from "./util/util" import { isModuleLinked } from "./util/ext-source-util" import { Garden } from "./garden" import { LogEntry } from "./logger/log-entry" @@ -110,32 +108,40 @@ export async function processModules( changeHandler = handler } - const watcher = new FSWatcher(garden, log) - - const restartPromise = new Promise(async (resolve) => { - await watcher.watchModules(modules, - async (changedModule: Module | null, configChanged: boolean) => { - if (configChanged) { - if (changedModule) { - log.info({ section: changedModule.name, msg: `Module configuration changed, reloading...`, symbol: "info" }) - } else { - log.info({ symbol: "info", msg: `Project configuration changed, reloading...` }) - } - resolve() - return - } - - if (changedModule) { - log.silly({ msg: `Files changed for module ${changedModule.name}` }) - changedModule = await garden.getModule(changedModule.name) - await Bluebird.map(changeHandler!(changedModule), (task) => garden.addTask(task)) - } - - await garden.processTasks() - }) - - registerCleanupFunction("clearAutoReloadWatches", () => { - watcher.close() + const modulesByName = keyBy(modules, "name") + + await garden.startWatcher() + + const restartPromise = new Promise((resolve) => { + garden.events.on("_restart", () => { + log.debug({ symbol: "info", msg: `Manual restart triggered` }) + resolve() + }) + + garden.events.on("projectConfigChanged", () => { + log.info({ symbol: "info", msg: `Project configuration changed, reloading...` }) + resolve() + }) + + garden.events.on("configAdded", (event) => { + log.info({ symbol: "info", msg: `Garden config added at ${event.path}, reloading...` }) + resolve() + }) + + garden.events.on("moduleConfigChanged", (event) => { + log.info({ symbol: "info", section: event.name, msg: `Module configuration changed, reloading...` }) + resolve() + }) + + garden.events.on("moduleSourcesChanged", async (event) => { + const changedModule = modulesByName[event.name] + + if (!changedModule) { + return + } + + await Bluebird.map(changeHandler!(changedModule), (task) => garden.addTask(task)) + await garden.processTasks() }) }) @@ -147,7 +153,6 @@ export async function processModules( } await restartPromise - watcher.close() return { taskResults: {}, // TODO: Return latest results for each task baseKey processed between restarts? diff --git a/garden-service/src/util/util.ts b/garden-service/src/util/util.ts index fa0ffeb4ad..9657447d67 100644 --- a/garden-service/src/util/util.ts +++ b/garden-service/src/util/util.ts @@ -109,7 +109,11 @@ export async function getChildDirNames(parentDir: string): Promise { return dirNames } -export async function getIgnorer(rootPath: string) { +export interface Ignorer { + ignores: (path: string) => boolean +} + +export async function getIgnorer(rootPath: string): Promise { // TODO: this doesn't handle nested .gitignore files, we should revisit const gitignorePath = join(rootPath, ".gitignore") const gardenignorePath = join(rootPath, ".gardenignore") diff --git a/garden-service/src/watch.ts b/garden-service/src/watch.ts index a2e7c6a668..7bbab29481 100644 --- a/garden-service/src/watch.ts +++ b/garden-service/src/watch.ts @@ -6,32 +6,46 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { watch } from "chokidar" -import { basename, parse, relative } from "path" +import { watch, FSWatcher } from "chokidar" +import { parse, relative } from "path" import { pathToCacheContext } from "./cache" import { Module } from "./types/module" -import { getIgnorer, scanDirectory } from "./util/util" import { MODULE_CONFIG_FILENAME } from "./constants" import { Garden } from "./garden" import { LogEntry } from "./logger/log-entry" +import * as klaw from "klaw" +import { registerCleanupFunction } from "./util/util" export type ChangeHandler = (module: Module | null, configChanged: boolean) => Promise -export class FSWatcher { - private watcher +/** + * Wrapper around the Chokidar file watcher. Emits events on `garden.events` when project files are changed. + * This needs to be enabled by calling the `.start()` method, and stopped with the `.stop()` method. + */ +export class Watcher { + private watcher: FSWatcher constructor(private garden: Garden, private log: LogEntry) { } - async watchModules(modules: Module[], changeHandler: ChangeHandler) { + /** + * Starts the file watcher. Idempotent. + * + * @param modules All configured modules in the project. + */ + start(modules: Module[]) { + // Only run one watcher for the process + if (this.watcher) { + return + } const projectRoot = this.garden.projectRoot - const ignorer = await getIgnorer(projectRoot) + const ignorer = this.garden.ignorer - const onFileChanged = this.makeFileChangedHandler(modules, changeHandler) + this.log.debug(`Watcher: Watching ${projectRoot}`) this.watcher = watch(projectRoot, { - ignored: (path, _) => { + ignored: (path: string, _: any) => { const relpath = relative(projectRoot, path) return relpath && ignorer.ignores(relpath) }, @@ -40,124 +54,130 @@ export class FSWatcher { }) this.watcher - .on("add", onFileChanged) - .on("change", onFileChanged) - .on("unlink", onFileChanged) + .on("add", this.makeFileChangedHandler("added", modules)) + .on("change", this.makeFileChangedHandler("modified", modules)) + .on("unlink", this.makeFileChangedHandler("removed", modules)) + .on("addDir", this.makeDirAddedHandler(modules)) + .on("unlinkDir", this.makeDirRemovedHandler(modules)) + + registerCleanupFunction("clearFileWatches", () => { + this.stop() + }) + } - this.watcher - .on("addDir", await this.makeDirAddedHandler(modules, changeHandler, ignorer)) - .on("unlinkDir", this.makeDirRemovedHandler(modules, changeHandler)) + stop(): void { + if (this.watcher) { + this.log.debug(`Watcher: Stopping`) + this.watcher.close() + delete this.watcher + } } - private makeFileChangedHandler(modules: Module[], changeHandler: ChangeHandler) { + private makeFileChangedHandler(type: string, modules: Module[]) { + return (path: string) => { + this.log.debug(`Watcher: File ${path} ${type}`) - return async (filePath: string) => { - this.log.debug("Start of changeHandler") + const parsed = parse(path) + const filename = parsed.base + const changedModule = modules.find(m => path.startsWith(m.path)) || null - const filename = basename(filePath) - const changedModule = modules.find(m => filePath.startsWith(m.path)) || null + if (filename === MODULE_CONFIG_FILENAME || filename === ".gitignore" || filename === ".gardenignore") { + this.invalidateCached(modules) - if (filename === "garden.yml" || filename === ".gitignore" || filename === ".gardenignore") { - await this.invalidateCachedForAll() - return changeHandler(changedModule, true) + if (changedModule) { + this.garden.events.emit("moduleConfigChanged", { name: changedModule.name }) + } else if (filename === MODULE_CONFIG_FILENAME) { + if (parsed.dir === this.garden.projectRoot) { + this.garden.events.emit("projectConfigChanged", {}) + } else { + this.garden.events.emit("configAdded", { path }) + } + } + + return } if (changedModule) { - this.invalidateCached(changedModule) + this.invalidateCached([changedModule]) + this.garden.events.emit("moduleSourcesChanged", { name: changedModule.name, pathChanged: path }) } - - return changeHandler(changedModule, false) - } - } - private async makeDirAddedHandler(modules: Module[], changeHandler: ChangeHandler, ignorer) { - + private makeDirAddedHandler(modules: Module[]) { const scanOpts = { filter: (path) => { const relPath = relative(this.garden.projectRoot, path) - return !ignorer.ignores(relPath) + return !this.garden.ignorer.ignores(relPath) }, } - return async (dirPath: string) => { + return (path: string) => { + this.log.debug(`Watcher: Directory ${path} added`) let configChanged = false - for await (const node of scanDirectory(dirPath, scanOpts)) { - if (!node) { - continue - } - - if (parse(node.path).base === MODULE_CONFIG_FILENAME) { - configChanged = true - } - } - - if (configChanged) { - // The added/removed dir contains one or more garden.yml files - await this.invalidateCachedForAll() - return changeHandler(null, true) - } - - const changedModule = modules.find(m => dirPath.startsWith(m.path)) || null + // Scan the added path to see if it contains a garden.yml file + klaw(path, scanOpts) + .on("data", (item) => { + const parsed = parse(item.path) + if (item.path !== path && parsed.base === MODULE_CONFIG_FILENAME) { + configChanged = true + this.garden.events.emit("configAdded", { path: item.path }) + } + }) + .on("error", (err) => { + if ((err).code === "ENOENT") { + // This can happen if the directory is removed while scanning + return + } else { + throw err + } + }) + .on("end", () => { + if (configChanged) { + // The added/removed dir contains one or more garden.yml files + this.invalidateCached(modules) + return + } - if (changedModule) { - this.invalidateCached(changedModule) - return changeHandler(changedModule, false) - } + const changedModule = modules.find(m => path.startsWith(m.path)) + if (changedModule) { + this.invalidateCached([changedModule]) + this.garden.events.emit("moduleSourcesChanged", { name: changedModule.name, pathChanged: path }) + } + }) } - } - private makeDirRemovedHandler(modules: Module[], changeHandler: ChangeHandler) { - - return async (dirPath: string) => { - - let changedModule: Module | null = null + private makeDirRemovedHandler(modules: Module[]) { + return (path: string) => { + this.log.debug(`Watcher: Directory ${path} removed`) for (const module of modules) { - - if (module.path.startsWith(dirPath)) { + if (module.path.startsWith(path)) { // at least one module's root dir was removed - await this.invalidateCachedForAll() - return changeHandler(null, true) + this.invalidateCached(modules) + this.garden.events.emit("moduleRemoved", { name: module.name }) + return } - if (dirPath.startsWith(module.path)) { + if (path.startsWith(module.path)) { // removed dir is a subdir of changedModule's root dir - if (!changedModule || module.path.startsWith(changedModule.path)) { - changedModule = module - } + this.invalidateCached([module]) + this.garden.events.emit("moduleSourcesChanged", { name: module.name, pathChanged: path }) } - - } - - if (changedModule) { - this.invalidateCached(changedModule) - return changeHandler(changedModule, false) } } - } - private invalidateCached(module: Module) { + private invalidateCached(modules: Module[]) { // invalidate the cache for anything attached to the module path or upwards in the directory tree - const cacheContext = pathToCacheContext(module.path) - this.garden.cache.invalidateUp(cacheContext) - } - - private async invalidateCachedForAll() { - for (const module of await this.garden.getModules()) { - this.invalidateCached(module) + for (const module of modules) { + const cacheContext = pathToCacheContext(module.path) + this.garden.cache.invalidateUp(cacheContext) } } - - close(): void { - this.watcher.close() - } - } diff --git a/garden-service/test/data/test-project-watch/garden.yml b/garden-service/test/data/test-project-watch/garden.yml new file mode 100644 index 0000000000..bd1f17723f --- /dev/null +++ b/garden-service/test/data/test-project-watch/garden.yml @@ -0,0 +1,11 @@ +project: + name: test-project-a + environmentDefaults: + variables: + some: variable + environments: + - name: local + providers: + - name: test-plugin + - name: test-plugin-b + - name: other diff --git a/garden-service/test/data/test-project-watch/module-a/foo.txt b/garden-service/test/data/test-project-watch/module-a/foo.txt new file mode 100644 index 0000000000..ba0e162e1c --- /dev/null +++ b/garden-service/test/data/test-project-watch/module-a/foo.txt @@ -0,0 +1 @@ +bar \ No newline at end of file diff --git a/garden-service/test/data/test-project-watch/module-a/garden.yml b/garden-service/test/data/test-project-watch/module-a/garden.yml new file mode 100644 index 0000000000..5ca3f64a6b --- /dev/null +++ b/garden-service/test/data/test-project-watch/module-a/garden.yml @@ -0,0 +1,13 @@ +module: + name: module-a + type: test + services: + - name: service-a + build: + command: [echo, A] + tests: + - name: unit + command: [echo, OK] + tasks: + - name: task-a + command: [echo, OK] diff --git a/garden-service/test/data/test-project-watch/module-a/subdir/foo2.txt b/garden-service/test/data/test-project-watch/module-a/subdir/foo2.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/garden-service/test/helpers.ts b/garden-service/test/helpers.ts index b7b2a89953..d6b7d78c05 100644 --- a/garden-service/test/helpers.ts +++ b/garden-service/test/helpers.ts @@ -36,7 +36,7 @@ import { helpers } from "../src/vcs/git" import { ModuleVersion } from "../src/vcs/base" import { GARDEN_DIR_NAME } from "../src/constants" import { EventBus, Events } from "../src/events" -import { ValueOf } from "../src/util/util" +import { ValueOf, Ignorer } from "../src/util/util" import { SourceConfig } from "../src/config/project" import { BuildDir } from "../src/build-dir" import timekeeper = require("timekeeper") @@ -249,6 +249,10 @@ class TestEventBus extends EventBus { this.log.push({ name, payload }) return super.emit(name, payload) } + + clearLog() { + this.log = [] + } } export class TestGarden extends Garden { @@ -261,9 +265,10 @@ export class TestGarden extends Garden { variables: PrimitiveMap, public readonly projectSources: SourceConfig[] = [], public readonly buildDir: BuildDir, + public readonly ignorer: Ignorer, log?, ) { - super(projectRoot, projectName, environmentName, variables, projectSources, buildDir, log) + super(projectRoot, projectName, environmentName, variables, projectSources, buildDir, ignorer, log) this.events = new TestEventBus() } } diff --git a/garden-service/test/setup.ts b/garden-service/test/setup.ts index fd7113904d..20dd68304e 100644 --- a/garden-service/test/setup.ts +++ b/garden-service/test/setup.ts @@ -3,10 +3,15 @@ import * as timekeeper from "timekeeper" import { Logger } from "../src/logger/logger" import { LogLevel } from "../src/logger/log-node" import { makeTestGardenA } from "./helpers" +// import { BasicTerminalWriter } from "../src/logger/writers/basic-terminal-writer" // make sure logger is initialized try { - Logger.initialize({ level: LogLevel.info }) + Logger.initialize({ + level: LogLevel.info, + // level: LogLevel.debug, + // writers: [new BasicTerminalWriter()], + }) } catch (_) { } // Global hooks diff --git a/garden-service/test/src/watch.ts b/garden-service/test/src/watch.ts new file mode 100644 index 0000000000..7f7b7ec9f6 --- /dev/null +++ b/garden-service/test/src/watch.ts @@ -0,0 +1,124 @@ +import { resolve } from "path" +import { TestGarden, dataDir, makeTestGarden } from "../helpers" +import { expect } from "chai" +import { CacheContext, pathToCacheContext } from "../../src/cache" +import pEvent = require("p-event") + +describe("Watcher", () => { + let garden: TestGarden + let modulePath: string + let moduleContext: CacheContext + + before(async () => { + garden = await makeTestGarden(resolve(dataDir, "test-project-watch")) + modulePath = resolve(garden.projectRoot, "module-a") + moduleContext = pathToCacheContext(modulePath) + await garden.startWatcher() + }) + + beforeEach(async () => { + garden.events.clearLog() + }) + + after(async () => { + await garden.close() + }) + + function emitEvent(name: string, payload: any) { + (garden).watcher.watcher.emit(name, payload) + } + + async function waitForEvent(name: string) { + return pEvent(garden.events, name, { timeout: 2000 }) + } + + it("should emit a moduleConfigChanged changed event when module config is changed", async () => { + emitEvent("change", resolve(modulePath, "garden.yml")) + expect(garden.events.log).to.eql([ + { name: "moduleConfigChanged", payload: { name: "module-a" } }, + ]) + }) + + it("should clear all module caches when a module config is changed", async () => { + emitEvent("change", resolve(modulePath, "garden.yml")) + expect(garden.cache.getByContext(moduleContext)).to.eql(new Map()) + }) + + it("should emit a projectConfigChanged changed event when project config is changed", async () => { + emitEvent("change", resolve(garden.projectRoot, "garden.yml")) + expect(garden.events.log).to.eql([ + { name: "projectConfigChanged", payload: {} }, + ]) + }) + + it("should emit a projectConfigChanged changed event when project config is removed", async () => { + emitEvent("unlink", resolve(garden.projectRoot, "garden.yml")) + expect(garden.events.log).to.eql([ + { name: "projectConfigChanged", payload: {} }, + ]) + }) + + it("should clear all module caches when project config is changed", async () => { + emitEvent("change", resolve(garden.projectRoot, "garden.yml")) + expect(garden.cache.getByContext(moduleContext)).to.eql(new Map()) + }) + + it("should emit a configAdded event when adding a garden.yml file", async () => { + const path = resolve(garden.projectRoot, "module-b", "garden.yml") + emitEvent("add", path) + expect(garden.events.log).to.eql([ + { name: "configAdded", payload: { path } }, + ]) + }) + + it("should emit a moduleSourcesChanged event when a module file is changed", async () => { + const pathChanged = resolve(modulePath, "foo.txt") + emitEvent("change", pathChanged) + expect(garden.events.log).to.eql([ + { name: "moduleSourcesChanged", payload: { name: "module-a", pathChanged } }, + ]) + }) + + it("should clear a module's cache when a module file is changed", async () => { + const pathChanged = resolve(modulePath, "foo.txt") + emitEvent("change", pathChanged) + expect(garden.cache.getByContext(moduleContext)).to.eql(new Map()) + }) + + it("should emit a configAdded event when a directory is added that contains a garden.yml file", async () => { + emitEvent("addDir", modulePath) + expect(await waitForEvent("configAdded")).to.eql({ + path: resolve(modulePath, "garden.yml"), + }) + }) + + it("should emit a moduleSourcesChanged event when a directory is added under a module directory", async () => { + const pathChanged = resolve(modulePath, "subdir") + emitEvent("addDir", pathChanged) + expect(await waitForEvent("moduleSourcesChanged")).to.eql({ + name: "module-a", + pathChanged, + }) + }) + + it("should clear a module's cache when a directory is added under a module directory", async () => { + const pathChanged = resolve(modulePath, "subdir") + emitEvent("addDir", pathChanged) + expect(garden.cache.getByContext(moduleContext)).to.eql(new Map()) + }) + + it("should emit a moduleRemoved event if a directory containing a module is removed", async () => { + emitEvent("unlinkDir", modulePath) + expect(garden.events.log).to.eql([ + { name: "moduleRemoved", payload: { name: "module-a" } }, + ]) + }) + + it("should emit a moduleSourcesChanged event if a directory within a module is removed", async () => { + const pathChanged = resolve(modulePath, "subdir") + emitEvent("unlinkDir", pathChanged) + expect(garden.events.log).to.eql([ + { name: "moduleSourcesChanged", payload: { name: "module-a", pathChanged } }, + ]) + }) +})