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 } }, + ]) + }) +})