diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 683a60eecf..2a7f0d66ec 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -21,7 +21,7 @@ The following option flags can be used with any of the CLI commands: | `--root` | `-r` | string | Override project root directory (defaults to working directory). | `--silent` | `-s` | boolean | Suppress log output. Same as setting --logger-type=quiet. | `--env` | `-e` | string | The environment (and optionally namespace) to work against. - | `--logger-type` | | `quiet` `basic` `fancy` `json` | Set logger type. + | `--logger-type` | | `quiet` `basic` `fancy` `fullscreen` `json` | Set logger type. fancy: updates log lines in-place when their status changes (e.g. when tasks complete), basic: appends a new log line when a log line's status changes, json: same as basic, but renders log lines as JSON, diff --git a/garden-service/package-lock.json b/garden-service/package-lock.json index 31137dab3e..378391f5f4 100644 --- a/garden-service/package-lock.json +++ b/garden-service/package-lock.json @@ -1448,9 +1448,9 @@ "dev": true }, "@types/node": { - "version": "12.12.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.21.tgz", - "integrity": "sha512-8sRGhbpU+ck1n0PGAUgVrWrWdjSW2aqNeyC15W88GRsMpSwzv6RJGlLhE7s2RhVSOdyDmxbqlWSeThq4/7xqlA==" + "version": "12.12.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.30.tgz", + "integrity": "sha512-sz9MF/zk6qVr3pAnM0BSQvYIBK44tS75QC5N+VbWSE4DjCV/pJ+UzCW/F+vVnl7TkOPcuwQureKNtSSwjBTaMg==" }, "@types/node-emoji": { "version": "1.8.1", @@ -9111,6 +9111,10 @@ "integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==", "dev": true }, + "neo-blessed": { + "version": "git+https://github.com/garden-io/neo-blessed.git#61820495367a5573332f4702ca36e261ef56bafd", + "from": "git+https://github.com/garden-io/neo-blessed.git#garden" + }, "nested-error-stacks": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.1.0.tgz", diff --git a/garden-service/package.json b/garden-service/package.json index cbb9908baa..0bcb152bd6 100644 --- a/garden-service/package.json +++ b/garden-service/package.json @@ -23,7 +23,7 @@ "static" ], "dependencies": { - "@hapi/joi": "https://github.com/garden-io/joi#master", + "@hapi/joi": "git+https://github.com/garden-io/joi.git#master", "@kubernetes/client-node": "^0.11.1", "JSONStream": "^1.3.5", "ajv": "^6.12.0", @@ -93,6 +93,7 @@ "minimatch": "^3.0.4", "mocha-logger": "^1.0.6", "moment": "^2.24.0", + "neo-blessed": "git+https://github.com/garden-io/neo-blessed.git#garden", "node-emoji": "^1.10.0", "node-forge": "^0.9.1", "normalize-path": "^3.0.0", @@ -169,7 +170,7 @@ "@types/lodash": "^4.14.149", "@types/minimatch": "^3.0.3", "@types/mocha": "^7.0.2", - "@types/node": "^12.12.21", + "@types/node": "^12.12.29", "@types/node-emoji": "^1.8.1", "@types/node-forge": "^0.9.2", "@types/normalize-path": "^3.0.0", diff --git a/garden-service/src/cli/cli.ts b/garden-service/src/cli/cli.ts index 506ff606ca..a865deeaee 100644 --- a/garden-service/src/cli/cli.ts +++ b/garden-service/src/cli/cli.ts @@ -300,6 +300,7 @@ export class GardenCli { // the screen the logs are printed. const headerLog = logger.placeholder() const log = logger.placeholder() + logger.info("") const footerLog = logger.placeholder() const contextOpts: GardenOpts = { @@ -483,6 +484,8 @@ export class GardenCli { } logger.stop() + logger.cleanup() + return { argv, code, errors, result: commandResult?.result } } } diff --git a/garden-service/src/commands/dev.ts b/garden-service/src/commands/dev.ts index 0b3c34fb28..1086b43fc2 100644 --- a/garden-service/src/commands/dev.ts +++ b/garden-service/src/commands/dev.ts @@ -97,7 +97,8 @@ export class DevCommand extends Command { const data = await readFile(ansiBannerPath) log.info(data.toString()) - log.info(chalk.gray.italic(`Good ${getGreetingTime()}! Let's get your environment wired up...\n`)) + log.info(chalk.gray.italic(`Good ${getGreetingTime()}! Let's get your environment wired up...`)) + log.info("") this.server = await startServer(footerLog) diff --git a/garden-service/src/logger/log-entry.ts b/garden-service/src/logger/log-entry.ts index ba119ec870..de4093ba89 100644 --- a/garden-service/src/logger/log-entry.ts +++ b/garden-service/src/logger/log-entry.ts @@ -89,6 +89,7 @@ export class LogEntry extends LogNode { public readonly errorData?: GardenError public readonly childEntriesInheritLevel?: boolean public readonly id?: string + public isPlaceholder?: boolean constructor(params: LogEntryConstructor) { super(params.level, params.parent, params.id) @@ -100,6 +101,7 @@ export class LogEntry extends LogNode { this.childEntriesInheritLevel = params.childEntriesInheritLevel this.metadata = params.metadata this.id = params.id + this.isPlaceholder = params.isPlaceholder if (!params.isPlaceholder) { this.update({ @@ -228,6 +230,7 @@ export class LogEntry extends LogNode { // Preserves status setState(params?: string | UpdateLogEntryParams): LogEntry { + this.isPlaceholder = false this.deepUpdate({ ...resolveParams(params) }) this.root.onGraphChange(this) return this diff --git a/garden-service/src/logger/logger.ts b/garden-service/src/logger/logger.ts index 9e18c4e9ef..d05bca3c6a 100644 --- a/garden-service/src/logger/logger.ts +++ b/garden-service/src/logger/logger.ts @@ -16,9 +16,10 @@ import { BasicTerminalWriter } from "./writers/basic-terminal-writer" import { FancyTerminalWriter } from "./writers/fancy-terminal-writer" import { JsonTerminalWriter } from "./writers/json-terminal-writer" import { parseLogLevel } from "../cli/helpers" +import { FullscreenTerminalWriter } from "./writers/fullscreen-terminal-writer" -export type LoggerType = "quiet" | "basic" | "fancy" | "json" -export const LOGGER_TYPES = new Set(["quiet", "basic", "fancy", "json"]) +export type LoggerType = "quiet" | "basic" | "fancy" | "fullscreen" | "json" +export const LOGGER_TYPES = new Set(["quiet", "basic", "fancy", "fullscreen", "json"]) export function getWriterInstance(loggerType: LoggerType, level: LogLevel) { switch (loggerType) { @@ -26,6 +27,8 @@ export function getWriterInstance(loggerType: LoggerType, level: LogLevel) { return new BasicTerminalWriter(level) case "fancy": return new FancyTerminalWriter(level) + case "fullscreen": + return new FullscreenTerminalWriter(level) case "json": return new JsonTerminalWriter(level) case "quiet": @@ -95,7 +98,7 @@ export class Logger extends LogNode { return instance } - private constructor(config: LoggerConfig) { + constructor(config: LoggerConfig) { super(config.level) this.writers = config.writers || [] this.useEmoji = config.useEmoji === false ? false : true @@ -134,6 +137,10 @@ export class Logger extends LogNode { this.getLogEntries().forEach((e) => e.stop()) this.writers.forEach((writer) => writer.stop()) } + + cleanup(): void { + this.writers.forEach((writer) => writer.cleanup()) + } } export function getLogger() { diff --git a/garden-service/src/logger/util.ts b/garden-service/src/logger/util.ts index de840b876b..b971c54ca4 100644 --- a/garden-service/src/logger/util.ts +++ b/garden-service/src/logger/util.ts @@ -9,7 +9,7 @@ import nodeEmoji from "node-emoji" import chalk from "chalk" import CircularJSON from "circular-json" -import { LogNode } from "./log-node" +import { LogNode, LogLevel } from "./log-node" import { LogEntry, LogEntryParams, EmojiName } from "./log-entry" import { isBuffer } from "util" import { deepMap } from "../util/util" @@ -22,14 +22,15 @@ export type LogOptsResolvers = { [K in keyof LogEntryParams]?: Function } export type ProcessNode = (node: T) => boolean -function traverseChildren(node: T | U, cb: ProcessNode) { +function traverseChildren(node: T | U, cb: ProcessNode, reverse = false) { const children = node.children - for (let idx = 0; idx < children.length; idx++) { - const proceed = cb(children[idx]) + for (let i = 0; i < children.length; i++) { + const index = reverse ? children.length - 1 - i : i + const proceed = cb(children[index]) if (!proceed) { return } - traverseChildren(children[idx], cb) + traverseChildren(children[index], cb) } } @@ -63,6 +64,64 @@ export function findLogNode(node: LogNode, predicate: ProcessNode): Log return found } +/** + * Given a LogNode, get a list of LogEntries that represent the last `lines` number of log lines nested under the node. + * Note that the returned number of lines may be slightly higher, so you should slice after rendering them (which + * you anyway need to do if you're wrapping the lines to a certain width). + * + * @param node the log node whose child entries we want to tail + * @param level maximum log level to include + * @param lines how many lines to aim for + */ +export function tailChildEntries(node: LogNode | LogEntry, level: LogLevel, lines: number): LogEntry[] { + let output: LogEntry[] = [] + let outputLines = 0 + + traverseChildren(node, (entry) => { + if (entry.level <= level) { + output.push(entry) + const msg = entry.getMessageState().msg || "" + outputLines += msg.length > 0 ? msg.split("\n").length : 0 + + if (outputLines >= lines) { + return false + } + } + return true + }) + + return output +} + +/** + * Get the log entry preceding the given `entry` in its tree, given the minimum log `level`. + */ +export function getPrecedingEntry(entry: LogEntry) { + if (!entry.parent) { + // This is the first entry in its tree + return + } + + const siblings = entry.parent.children + const index = siblings.findIndex((e) => e.key === entry.key) + + if (index === 0) { + // The nearest entry is the parent + return entry.parent + } else { + // The nearest entry is the last entry nested under the next sibling above, + // or the sibling itself if it has no child nodes + const sibling = siblings[index - 1] + const siblingChildren = getChildEntries(sibling) + + if (siblingChildren.length > 0) { + return siblingChildren[siblingChildren.length - 1] + } else { + return sibling + } + } +} + interface StreamWriteExtraParam { noIntercept?: boolean } diff --git a/garden-service/src/logger/writers/base.ts b/garden-service/src/logger/writers/base.ts index 339cbc48d6..3d785821a0 100644 --- a/garden-service/src/logger/writers/base.ts +++ b/garden-service/src/logger/writers/base.ts @@ -15,7 +15,7 @@ export abstract class Writer { constructor(public level: LogLevel = LogLevel.info) {} - abstract render(...args): string | string[] | null abstract onGraphChange(entry: LogEntry, logger: Logger): void abstract stop(): void + cleanup(): void {} } diff --git a/garden-service/src/logger/writers/fullscreen-terminal-writer.ts b/garden-service/src/logger/writers/fullscreen-terminal-writer.ts new file mode 100644 index 0000000000..e20628d951 --- /dev/null +++ b/garden-service/src/logger/writers/fullscreen-terminal-writer.ts @@ -0,0 +1,511 @@ +/* + * Copyright (C) 2018-2020 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import cliCursor from "cli-cursor" +import elegantSpinner from "elegant-spinner" +import wrapAnsi from "wrap-ansi" +import chalk from "chalk" +import blessed from "neo-blessed" + +import { formatForTerminal, leftPad, renderMsg } from "../renderers" +import { LogEntry } from "../log-entry" +import { Logger } from "../logger" +import { LogLevel } from "../log-node" +import { getChildEntries, getPrecedingEntry } from "../util" +import { Writer } from "./base" +import { shutdown } from "../../util/util" +import { dedent } from "../../util/string" +import { max, sum, min } from "lodash" + +const INTERVAL_MS = 60 + +const spinnerStyle = chalk.cyan +const spinnerBytes = spinnerStyle(elegantSpinner()()).length + +export type Coords = [number, number] + +export interface TerminalEntry { + key: string + lines: string[] + lineNumber: number + spinnerX?: number +} + +export interface KeyHandler { + keys: string[] + listener: (key: string) => void +} + +export class FullscreenTerminalWriter extends Writer { + type = "fullscreen" + + private spinners: { [key: string]: Function } + private intervalID: NodeJS.Timer | null + private initialized: boolean + private errorMessages: string[] + private scrolling: boolean + private logger: Logger + private terminalEntries: { [key: string]: TerminalEntry } = {} + private spinningEntries: { [key: string]: TerminalEntry } = {} + private contentHeight: number + + public screen: any + public main: any + public bottom: any + public keyHandlers: KeyHandler[] + + constructor(level: LogLevel = LogLevel.info, private spinInterval = INTERVAL_MS) { + super(level) + this.intervalID = null + this.spinners = {} // Each entry has it's own spinner + this.initialized = false + this.errorMessages = [] + this.scrolling = false + this.keyHandlers = [] + this.terminalEntries = {} + this.spinningEntries = {} + this.contentHeight = 0 + } + + private init(logger: Logger) { + this.logger = logger + + this.screen = this.createScreen() + + this.main = blessed.box({ + parent: this.screen, + top: 0, + left: 0, + width: "100%", + height: "100%-2", + content: "", + scrollable: true, + alwaysScroll: true, + border: false, + padding: { + left: 1, + top: 1, + bottom: 1, + right: 1, + }, + style: { + fg: "white", + }, + scrollbar: { + bg: "white", + }, + }) + + this.bottom = blessed.box({ + parent: this.screen, + top: "100%-2", + left: 0, + content: this.renderCommandLine(), + scrollable: false, + border: false, + padding: { + left: 1, + right: 1, + bottom: 1, + top: 0, + }, + style: { + fg: "white", + border: {}, + }, + }) + + // TODO: may need to revisit how we terminate + this.addKeyHandler({ + keys: ["C-c"], + listener: () => { + this.cleanup() + shutdown(0) + }, + }) + + this.addKeyHandler({ + keys: ["0", "1", "2", "3", "4"], + listener: (key) => { + this.changeLevel(parseInt(key, 10)) + this.bottom.setContent(this.renderCommandLine()) + this.flashMessage(`Set log level to ${chalk.white.bold(LogLevel[this.level])} [${this.level}]`) + this.screen.render() + }, + }) + + // Debug helper + this.addKeyHandler({ + keys: ["C-d"], + listener: () => { + this.flashMessage(dedent` + Scroll: ${this.main.getScroll()} / ${this.main.getScrollPerc()}% + Height: ${this.main.height} + Total entries: ${sum(Object.values(this.terminalEntries).map((e) => e.lines.length))} + Total lines: ${this.contentHeight} + `) + this.screen.render() + }, + }) + + // Add scroll handlers + this.addKeyHandler({ + keys: ["pageup"], + listener: () => { + this.scrolling = true + this.main.scrollTo(this.main.getScroll() - this.main.height - 2) + this.screen.render() + }, + }) + + this.addKeyHandler({ + keys: ["pagedown"], + listener: () => { + this.main.scrollTo(this.main.getScroll() + this.main.height - 2) + if (this.main.getScrollPerc() === 100) { + this.scrolling = false + } + this.screen.render() + }, + }) + + this.screen.append(this.main) + this.screen.append(this.bottom) + this.main.focus() + this.screen.render() + + // TODO: do full re-render on resize to fix line wraps + + this.initialized = true + } + + protected createScreen() { + return blessed.screen({ + title: "garden", + smartCSR: true, + autoPadding: false, + warnings: true, + fullUnicode: true, + ignoreLocked: ["C-c", "C-z"], + }) + } + + protected getWidth() { + return this.main.width + } + + getContent() { + return this.main?.getContent() || "" + } + + /** + * Flash a log message in a box + */ + flashMessage(message: string, duration = 2000) { + if (!this.initialized) { + return + } + + const box = blessed.box({ + parent: this.screen, + top: "center", + left: "center", + align: "center", + shrink: true, + content: message, + scrollable: false, + border: { + type: "line", + }, + style: { + fg: "white", + }, + shadow: true, + padding: { + left: 1, + right: 1, + bottom: 0, + top: 0, + }, + }) + this.screen.append(box) + this.screen.render() + + setTimeout(() => { + this.screen.remove(box) + this.screen.render() + }, duration) + } + + /** + * Return the currently visible range of lines (inclusive on both ends). + */ + getVisibleRange() { + const scrollOffset = this.main?.getScroll() || 0 + const top = max([scrollOffset - this.main?.height || 0, 0]) + const bottom = min([scrollOffset, this.contentHeight]) + return [top, bottom] + } + + addKeyHandler(handler: KeyHandler) { + this.keyHandlers.push(handler) + this.screen.key(handler.keys, handler.listener) + } + + removeKeyHandler(handler: KeyHandler) { + this.screen.unkey(handler.keys, handler.listener) + } + + changeLevel(level: LogLevel) { + this.level = level + + // Do a full re-render (if anything has been rendered) + if (this.logger && this.main) { + this.reRender() + } + } + + cleanup() { + this.screen.destroy() + cliCursor.show(process.stdout) + for (const line of this.errorMessages) { + process.stdout.write(line) + } + this.errorMessages = [] + } + + private renderCommandLine() { + const level = `${this.level}=${LogLevel[this.level]}` + return chalk.gray(`[page-up/down]: scroll [0-4]: set log level (${level}) [ctrl-c]: quit`) + } + + private spin(): void { + const [from, to] = this.getVisibleRange() + + for (const e of Object.values(this.spinningEntries)) { + // This should always be set if the entry is in spinningEntries + const x = e.spinnerX || 0 + + // ignore spinners outside of visible range + if (e.lineNumber < from || e.lineNumber > to) { + continue + } + + const line = this.main.getLine(e.lineNumber) + this.main.setLine( + e.lineNumber, + line.substring(0, x) + spinnerStyle(this.tickSpinner(e.key)) + line.substring(x + spinnerBytes) + ) + } + + this.screen.render() + } + + private startLoop(): void { + if (!this.intervalID) { + this.intervalID = setInterval(() => this.spin(), this.spinInterval) + } + } + + private stopLoop(): void { + if (this.intervalID) { + clearInterval(this.intervalID) + this.intervalID = null + } + } + + private tickSpinner(key: string): string { + if (!this.spinners[key]) { + this.spinners[key] = elegantSpinner() + } + return this.spinners[key]() + } + + public onGraphChange(entry: LogEntry, logger: Logger): void { + if (entry.level === LogLevel.error) { + this.errorMessages.push(formatForTerminal(entry, "basic")) + } + + if (!this.initialized) { + this.init(logger) + } + + this.renderLogEntry(entry) + } + + public stop(): void { + this.stopLoop() + } + + private reRender() { + if (!this.initialized) { + return + } + + this.main.setContent("") + this.contentHeight = 0 + this.terminalEntries = {} + this.spinningEntries = {} + + for (const entry of getChildEntries(this.logger)) { + this.renderLogEntry(entry) + } + } + + private renderLogEntry(logEntry: LogEntry) { + if (logEntry.level > this.level) { + return + } + + const currentTerminalEntry = this.terminalEntries[logEntry.key] + let newEntry: TerminalEntry + + if (currentTerminalEntry) { + // If entry has already been rendered, update it directly, inserting/deleting lines if its height changed + newEntry = this.toTerminalEntry(logEntry, currentTerminalEntry.lineNumber) + + const currentHeight = currentTerminalEntry.lines.length + const newLines = newEntry.lines + const newHeight = newLines.length + const lineDiff = newHeight - currentHeight + + if (lineDiff === 0) { + // Overwrite the lines + for (let y = 0; y < newHeight; y++) { + this.main.setLine(currentTerminalEntry.lineNumber + y, newLines[y]) + } + } else if (lineDiff < 0) { + // Overwrite the first current lines + for (let y = 0; y < newHeight; y++) { + this.main.setLine(currentTerminalEntry.lineNumber + y, newLines[y]) + } + // Delete the remaining lines + for (let y = 0; y < -lineDiff; y++) { + this.main.deleteLine(currentTerminalEntry.lineNumber + newHeight + y) + } + } else if (lineDiff > 0) { + // Overwrite the current lines + for (let y = 0; y < currentHeight; y++) { + this.main.setLine(currentTerminalEntry.lineNumber + y, newLines[y]) + } + // Insert the remaining lines + for (let y = 0; y < lineDiff; y++) { + this.main.insertLine(currentTerminalEntry.lineNumber + currentHeight + y, newLines[currentHeight + y]) + } + } + + this.contentHeight += lineDiff + this.updateLineNumbers(currentTerminalEntry.lineNumber + currentHeight, lineDiff) + } else { + // If entry has not been previously rendered, figure out the preceding visible entries' position and insert below + let precedingLogEntry = getPrecedingEntry(logEntry) + + while (precedingLogEntry && this.level < precedingLogEntry.level) { + precedingLogEntry = getPrecedingEntry(precedingLogEntry) + } + + if (precedingLogEntry) { + // We insert the new entry below the preceding one + const precedingTerminalEntry = this.terminalEntries[precedingLogEntry.key] + const precedingEntryHeight = precedingTerminalEntry.lines.length + newEntry = this.toTerminalEntry(logEntry, precedingTerminalEntry.lineNumber + precedingEntryHeight) + + for (let y = 0; y < newEntry.lines.length; y++) { + this.main.insertLine(precedingTerminalEntry.lineNumber + precedingEntryHeight + y, newEntry.lines[y]) + } + + this.contentHeight += newEntry.lines.length + this.updateLineNumbers(newEntry.lineNumber, newEntry.lines.length) + } else { + // No preceding entry, we insert at the bottom + newEntry = this.toTerminalEntry(logEntry, this.contentHeight) + for (const line of newEntry.lines) { + this.main.pushLine(line) + } + this.contentHeight += newEntry.lines.length + } + } + + this.setTerminalEntry(newEntry) + + if (!this.scrolling) { + this.main.scrollTo(this.contentHeight) + } + + this.screen.render() + this.startLoop() + } + + private setTerminalEntry(entry: TerminalEntry) { + this.terminalEntries[entry.key] = entry + + if (entry.spinnerX !== undefined) { + this.spinningEntries[entry.key] = entry + } else if (this.spinningEntries[entry.key]) { + delete this.spinningEntries[entry.key] + } + } + + private updateLineNumbers(from: number, offset: number) { + for (const e of Object.values(this.terminalEntries)) { + if (e.lineNumber >= from) { + e.lineNumber += offset + } + } + } + + private toTerminalEntry(entry: LogEntry, lineNumber: number): TerminalEntry { + let spinnerFrame = "" + let spinnerX: number | undefined + + if (entry.getMessageState().status === "active") { + spinnerX = leftPad(entry).length + spinnerFrame = this.tickSpinner(entry.key) + } else { + delete this.spinners[entry.key] + } + + const text = [entry] + .map((e) => (e.fromStdStream ? renderMsg(e) : formatForTerminal(e, "fancy"))) + .map((str) => + spinnerFrame ? `${str.slice(0, spinnerX)}${spinnerStyle(spinnerFrame)} ${str.slice(spinnerX)}` : str + ) + .map((str) => { + const leadingSpace = str.match(/ */)![0] + const wrapped = wrapAnsi(str, this.getWidth() - 4 - leadingSpace.length, { + trim: true, + hard: true, + }) + return wrapped + .split("\n") + .map((l) => (leadingSpace + l).trimEnd()) + .join("\n") + }) + .pop()! + + let lines: string[] + + if (entry.isPlaceholder) { + lines = [] + } else if (text === "") { + lines = [""] + } else { + lines = text.split("\n").slice(0, -1) + } + + // Need to make blank lines a single space to work around a blessed bug + lines = lines.map((l) => l || " ") + + return { + key: entry.key, + lineNumber, + spinnerX, + lines, + } + } +} diff --git a/garden-service/src/process.ts b/garden-service/src/process.ts index e3ac782fbc..f6b4d32967 100644 --- a/garden-service/src/process.ts +++ b/garden-service/src/process.ts @@ -70,9 +70,11 @@ export async function processModules({ } if (watch && !!footerLog) { + footerLog.info("") + garden.events.on("taskGraphProcessing", () => { const emoji = printEmoji("hourglass_flowing_sand", footerLog) - footerLog.setState(`\n${emoji} Processing...`) + footerLog.setState(`${emoji} Processing...`) }) } @@ -97,8 +99,7 @@ export async function processModules({ const waiting = () => { if (!!footerLog) { - const emoji = printEmoji("clock2", footerLog) - footerLog.setState(`\n${emoji} ${chalk.gray("Waiting for code changes...")}`) + footerLog.setState({ emoji: "clock2", msg: chalk.gray("Waiting for code changes...") }) } garden.events.emit("watchingForChanges", {}) diff --git a/garden-service/static/garden-banner-2.txt b/garden-service/static/garden-banner-2.txt index a32a9afacd..545a1e9ea4 100755 --- a/garden-service/static/garden-banner-2.txt +++ b/garden-service/static/garden-banner-2.txt @@ -1,7 +1,7 @@ -                                    -                                    -                                    -                                    -                                    -                                    -                                    +                                                                       +                                                                       +                                                                       +                                                                       +                                                                       +                                                                       +                                                                       diff --git a/garden-service/test/unit/src/logger/writers/fullscreen-terminal-writer.ts b/garden-service/test/unit/src/logger/writers/fullscreen-terminal-writer.ts new file mode 100644 index 0000000000..b56a9f1b97 --- /dev/null +++ b/garden-service/test/unit/src/logger/writers/fullscreen-terminal-writer.ts @@ -0,0 +1,364 @@ +/* + * Copyright (C) 2018-2020 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { expect } from "chai" +import blessed from "neo-blessed" + +import { Logger } from "../../../../../src/logger/logger" +import { FullscreenTerminalWriter } from "../../../../../src/logger/writers/fullscreen-terminal-writer" +import { LogLevel } from "../../../../../src/logger/log-node" +import stripAnsi from "strip-ansi" +import { dedent } from "../../../../../src/util/string" +import { Writable, WritableOptions } from "stream" +import chalk from "chalk" + +const width = 20 +const height = 10 + +class TestWriter extends FullscreenTerminalWriter { + protected createScreen() { + return blessed.screen({ + title: "test", + smartCSR: true, + autoPadding: false, + warnings: true, + fullUnicode: true, + ignoreLocked: ["C-c", "C-z"], + output: new MockStdout(), + }) + } +} + +describe("FullscreenTerminalWriter", () => { + let writer: TestWriter + let logger: Logger + + beforeEach(() => { + // Setting a very long spin interval so that we can control it manually + writer = new TestWriter(LogLevel.info, 99999999) + logger = new Logger({ level: LogLevel.info, writers: [writer] }) + }) + + function getStrippedContent() { + return stripAnsi(writer.getContent()).trim() + } + + describe("onGraphChange", () => { + it("should correctly render the first entry", () => { + const one = logger.info("one") + + expect(getStrippedContent()).to.eql("one") + expect(writer["contentHeight"]).to.equal(1) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + }) + + it("should append a new entry", () => { + const one = logger.info("one") + const two = logger.info("two") + + expect(getStrippedContent()).to.eql(dedent` + one + two + `) + expect(writer["contentHeight"]).to.equal(2) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][two.key].lineNumber).to.equal(1) + }) + + it("should wrap a long line", () => { + const one = logger.info("this is a long line that should wrap appropriately") + const two = logger.info("two") + + expect(getStrippedContent()).to.eql(dedent` + this is a long + line that should + wrap appropriately + two + `) + expect(writer["contentHeight"]).to.equal(4) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][two.key].lineNumber).to.equal(3) + }) + + it("should wrap a nested long line with appropriate indent", () => { + const one = logger.info("one") + const two = one.info("this is a long line that should wrap appropriately") + + expect(getStrippedContent()).to.eql(dedent` + one + this is a long + line that + should wrap + appropriately + `) + expect(writer["contentHeight"]).to.equal(5) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][two.key].lineNumber).to.equal(1) + }) + + it("should wrap a long line with color code", () => { + const one = logger.info("this is a long line that " + chalk.white.bold("should wrap appropriately")) + const two = logger.info("two") + + expect(getStrippedContent()).to.eql(dedent` + this is a long + line that should + wrap appropriately + two + `) + expect(writer["contentHeight"]).to.equal(4) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][two.key].lineNumber).to.equal(3) + }) + + it("should ignore an entry with a level above writer level", () => { + const one = logger.info("one") + const two = logger.debug("two") + + expect(getStrippedContent()).to.eql(dedent` + one + `) + expect(writer["contentHeight"]).to.equal(1) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][two.key]).to.not.exist + }) + + it("should write a nested key even if its parent is hidden", () => { + const one = logger.info("one") + const two = logger.debug("two") + const three = two.info("three") + + expect(getStrippedContent()).to.eql(dedent` + one + three + `) + expect(writer["contentHeight"]).to.equal(2) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][two.key]).to.not.exist + expect(writer["terminalEntries"][three.key].lineNumber).to.equal(1) + }) + + it("should insert an entry", () => { + const one = logger.info("one") + const oneChild = one.info("one-child") + const two = logger.info("two") + + expect(getStrippedContent()).to.eql(dedent` + one + one-child + two + `) + expect(writer["contentHeight"]).to.equal(3) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][oneChild.key].lineNumber).to.equal(1) + expect(writer["terminalEntries"][two.key].lineNumber).to.equal(2) + }) + + it("should insert an entry after a nested entry", () => { + const one = logger.info("one") + const two = logger.info("two") + const oneChild = one.info("one-child") + const oneChild2 = one.info("one-child2") + + expect(getStrippedContent()).to.eql(dedent` + one + one-child + one-child2 + two + `) + expect(writer["contentHeight"]).to.equal(4) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][oneChild.key].lineNumber).to.equal(1) + expect(writer["terminalEntries"][oneChild2.key].lineNumber).to.equal(2) + expect(writer["terminalEntries"][two.key].lineNumber).to.equal(3) + }) + + it("should insert a nested entry", () => { + const one = logger.info("one") + const two = logger.info("two") + const oneChild = one.info("one-child") + const oneNested = oneChild.info("one-nested") + + expect(getStrippedContent()).to.eql(dedent` + one + one-child + one-nested + two + `) + expect(writer["contentHeight"]).to.equal(4) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][oneChild.key].lineNumber).to.equal(1) + expect(writer["terminalEntries"][oneNested.key].lineNumber).to.equal(2) + expect(writer["terminalEntries"][two.key].lineNumber).to.equal(3) + }) + + it("should render a blank entry", () => { + const one = logger.info("one") + const two = logger.info("") + const three = logger.info("three") + + expect(getStrippedContent()).to.equal("one\n \nthree") + expect(writer["contentHeight"]).to.equal(3) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][two.key].lineNumber).to.equal(1) + expect(writer["terminalEntries"][three.key].lineNumber).to.equal(2) + }) + + it("should insert a blank entry", () => { + const one = logger.info("one") + const oneChild = one.info("") + const two = logger.info("two") + + expect(getStrippedContent()).to.equal("one\n \ntwo") + expect(writer["contentHeight"]).to.equal(3) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][oneChild.key].lineNumber).to.equal(1) + expect(writer["terminalEntries"][two.key].lineNumber).to.equal(2) + }) + + it("should replace a placeholder", () => { + const one = logger.placeholder() + const two = logger.info("two") + one.setState("one") + + expect(getStrippedContent()).to.eql(dedent` + one + two + `) + expect(writer["contentHeight"]).to.equal(2) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][two.key].lineNumber).to.equal(1) + }) + + it("should modify an entry in-place", () => { + const one = logger.info("one") + one.setState("one-b") + const two = logger.info("two") + + expect(getStrippedContent()).to.eql(dedent` + one-b + two + `) + expect(writer["contentHeight"]).to.equal(2) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][two.key].lineNumber).to.equal(1) + }) + + it("should extend an entry with a taller one", () => { + const one = logger.info("one") + one.setState("one\none-b") + const two = logger.info("two") + + expect(getStrippedContent()).to.eql(dedent` + one + one-b + two + `) + expect(writer["contentHeight"]).to.equal(3) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][two.key].lineNumber).to.equal(2) + }) + + it("should shorten an entry", () => { + const one = logger.info("one\none-b") + one.setState("one") + const two = logger.info("two") + + expect(getStrippedContent()).to.eql(dedent` + one + two + `) + expect(writer["contentHeight"]).to.equal(2) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][two.key].lineNumber).to.equal(1) + }) + + it("should insert an entry with a spinner", () => { + const one = logger.info("one") + const two = logger.info({ status: "active", msg: "two" }) + + expect(getStrippedContent()).to.eql(dedent` + one + ⠙ two + `) + expect(writer["contentHeight"]).to.equal(2) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][two.key].lineNumber).to.equal(1) + }) + + it("should spin a spinner", () => { + const one = logger.info("one") + const two = logger.info({ status: "active", msg: "two" }) + + writer["spin"]() + + expect(getStrippedContent()).to.eql(dedent` + one + ⠹ two + `) + expect(writer["contentHeight"]).to.equal(2) + expect(writer["terminalEntries"][one.key].lineNumber).to.equal(0) + expect(writer["terminalEntries"][two.key].lineNumber).to.equal(1) + }) + }) + + describe("getVisibleRange", () => { + it("should get the visible range when logs are within a page", () => { + logger.info("one") + logger.info("two") + + const [from, to] = writer.getVisibleRange() + + expect(from).to.equal(0) + expect(to).to.equal(2) + }) + + it("should get the visible range when logs are longer than a page", () => { + for (let i = 0; i < height * 2; i++) { + logger.info("line " + i) + } + + const [from, to] = writer.getVisibleRange() + + expect(from).to.equal(height - 1) + expect(to).to.equal(height * 2 - 1) + }) + }) +}) + +class MockStdout extends Writable { + private _data: Array = [] + + // Adding padding + columns = width + 2 + rows = height + 2 + + constructor(options?: WritableOptions) { + super(options) + } + + // tslint:disable-next-line: function-name + public _write(data: Buffer | string, encoding: string, callback: Function) { + this.emit("data", Buffer.isBuffer(data) ? data.toString("utf8" || encoding) : data) + callback() + } + + public end(): void { + this.emit("end") + super.end() + } + + public write(data: any): boolean { + this._data.push(data) + return super.write(data) + } + + public data(): Array { + return this._data.slice(0) + } +}