From f4c85d3c7dd960016fe5435ff951e23d35018543 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:48:45 +0000 Subject: [PATCH] feat: implement CLIReporterPlugin - Created `CLIReporterPlugin` to hook into EventBus lifecycle events and output task progress to the CLI. - Integrated `performance.now()` to measure workflow duration. - Exported plugin from `src/index.ts`. - Updated README.md with configuration instructions. - Added test coverage via `vitest`. - Marked all task items as complete and archived the specification using the OpenSpec workflow. Co-authored-by: thalesraymond <32554150+thalesraymond@users.noreply.github.com> --- README.md | 14 +++ openspec/changes/add-cli-reporter/tasks.md | 8 -- .../2026-04-03-add-cli-reporter}/design.md | 0 .../2026-04-03-add-cli-reporter}/proposal.md | 0 .../specs/cli-reporter/spec.md | 0 .../2026-04-03-add-cli-reporter/tasks.md | 8 ++ src/index.ts | 3 + src/plugins/CLIReporterPlugin.ts | 53 +++++++++ tests/plugins/CLIReporterPlugin.test.ts | 106 ++++++++++++++++++ 9 files changed, 184 insertions(+), 8 deletions(-) delete mode 100644 openspec/changes/add-cli-reporter/tasks.md rename openspec/changes/{add-cli-reporter => archive/2026-04-03-add-cli-reporter}/design.md (100%) rename openspec/changes/{add-cli-reporter => archive/2026-04-03-add-cli-reporter}/proposal.md (100%) rename openspec/changes/{add-cli-reporter => archive/2026-04-03-add-cli-reporter}/specs/cli-reporter/spec.md (100%) create mode 100644 openspec/changes/archive/2026-04-03-add-cli-reporter/tasks.md create mode 100644 src/plugins/CLIReporterPlugin.ts create mode 100644 tests/plugins/CLIReporterPlugin.test.ts diff --git a/README.md b/README.md index 0e3e09b..27b5649 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,20 @@ runner.use(MyLoggerPlugin); await runner.execute(steps); ``` +### Built-in Plugins + +#### CLIReporterPlugin + +The `CLIReporterPlugin` provides real-time structured CLI display to track task progress, visualize success/failure/skip states, and review summarized performance metrics. + +```typescript +import { TaskRunnerBuilder, CLIReporterPlugin } from "@calmo/task-runner"; + +const runner = new TaskRunnerBuilder(context) + .usePlugin(new CLIReporterPlugin()) + .build(); +``` + ## Skip Propagation If a task fails or is skipped, the `TaskRunner` automatically marks all subsequent tasks that depend on it as `skipped`. This ensures that your pipeline doesn't attempt to run steps with missing prerequisites. diff --git a/openspec/changes/add-cli-reporter/tasks.md b/openspec/changes/add-cli-reporter/tasks.md deleted file mode 100644 index 1b19cba..0000000 --- a/openspec/changes/add-cli-reporter/tasks.md +++ /dev/null @@ -1,8 +0,0 @@ -## 1. Implementation - -- [ ] 1.1 Create `src/plugins/CLIReporterPlugin.ts` implementing `Plugin`. -- [ ] 1.2 Implement event listeners for `taskStart`, `taskEnd`, and `taskSkipped`. -- [ ] 1.3 Implement real-time console rendering logic (e.g., using ANSI escape sequences or a minimal progress library) to display running tasks. -- [ ] 1.4 Implement a summary output function to display total execution time, successful tasks, failed tasks, and skipped tasks at the end of the workflow. -- [ ] 1.5 Write unit and integration tests in `tests/plugins/CLIReporterPlugin.test.ts`. -- [ ] 1.6 Update `README.md` to document the new `CLIReporterPlugin` usage. diff --git a/openspec/changes/add-cli-reporter/design.md b/openspec/changes/archive/2026-04-03-add-cli-reporter/design.md similarity index 100% rename from openspec/changes/add-cli-reporter/design.md rename to openspec/changes/archive/2026-04-03-add-cli-reporter/design.md diff --git a/openspec/changes/add-cli-reporter/proposal.md b/openspec/changes/archive/2026-04-03-add-cli-reporter/proposal.md similarity index 100% rename from openspec/changes/add-cli-reporter/proposal.md rename to openspec/changes/archive/2026-04-03-add-cli-reporter/proposal.md diff --git a/openspec/changes/add-cli-reporter/specs/cli-reporter/spec.md b/openspec/changes/archive/2026-04-03-add-cli-reporter/specs/cli-reporter/spec.md similarity index 100% rename from openspec/changes/add-cli-reporter/specs/cli-reporter/spec.md rename to openspec/changes/archive/2026-04-03-add-cli-reporter/specs/cli-reporter/spec.md diff --git a/openspec/changes/archive/2026-04-03-add-cli-reporter/tasks.md b/openspec/changes/archive/2026-04-03-add-cli-reporter/tasks.md new file mode 100644 index 0000000..b2d254b --- /dev/null +++ b/openspec/changes/archive/2026-04-03-add-cli-reporter/tasks.md @@ -0,0 +1,8 @@ +## 1. Implementation + +- [x] 1.1 Create `src/plugins/CLIReporterPlugin.ts` implementing `Plugin`. +- [x] 1.2 Implement event listeners for `taskStart`, `taskEnd`, and `taskSkipped`. +- [x] 1.3 Implement real-time console rendering logic (e.g., using ANSI escape sequences or a minimal progress library) to display running tasks. +- [x] 1.4 Implement a summary output function to display total execution time, successful tasks, failed tasks, and skipped tasks at the end of the workflow. +- [x] 1.5 Write unit and integration tests in `tests/plugins/CLIReporterPlugin.test.ts`. +- [x] 1.6 Update `README.md` to document the new `CLIReporterPlugin` usage. diff --git a/src/index.ts b/src/index.ts index b68eba3..aa144c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,3 +9,6 @@ export type { TaskRetryConfig } from "./contracts/TaskRetryConfig.js"; export type { TaskStep } from "./TaskStep.js"; export type { TaskResult } from "./TaskResult.js"; export type { TaskStatus } from "./TaskStatus.js"; + +// Plugins +export { CLIReporterPlugin } from "./plugins/CLIReporterPlugin.js"; diff --git a/src/plugins/CLIReporterPlugin.ts b/src/plugins/CLIReporterPlugin.ts new file mode 100644 index 0000000..c35cf4d --- /dev/null +++ b/src/plugins/CLIReporterPlugin.ts @@ -0,0 +1,53 @@ +import { Plugin, PluginContext } from "../contracts/Plugin.js"; + +/** + * A plugin that reports task execution progress and summarizes results in the CLI. + */ +export class CLIReporterPlugin implements Plugin { + public readonly name = "cli-reporter"; + public readonly version = "1.0.0"; + + private startTime = 0; + private successCount = 0; + private failureCount = 0; + private skippedCount = 0; + + public install(context: PluginContext): void { + context.events.on("workflowStart", () => { + this.startTime = performance.now(); + this.successCount = 0; + this.failureCount = 0; + this.skippedCount = 0; + console.log("\nšŸš€ Starting TaskRunner workflow..."); + }); + + context.events.on("taskStart", ({ step }) => { + console.log(`ā³ Starting: ${step.name}`); + }); + + context.events.on("taskEnd", ({ step, result }) => { + if (result.status === "success") { + this.successCount++; + console.log(`āœ… Success: ${step.name}`); + } else { + this.failureCount++; + console.log(`āŒ Failed: ${step.name}${result.error ? ` - ${result.error}` : ""}`); + } + }); + + context.events.on("taskSkipped", ({ step, result }) => { + this.skippedCount++; + const reason = result.error ? ` - ${result.error}` : ""; + console.log(`ā­ļø Skipped: ${step.name}${reason}`); + }); + + context.events.on("workflowEnd", () => { + const duration = performance.now() - this.startTime; + console.log(`\nšŸ Workflow Completed in ${duration.toFixed(2)}ms`); + console.log("šŸ“Š Summary:"); + console.log(` āœ… Success: ${this.successCount}`); + console.log(` āŒ Failed: ${this.failureCount}`); + console.log(` ā­ļø Skipped: ${this.skippedCount}\n`); + }); + } +} diff --git a/tests/plugins/CLIReporterPlugin.test.ts b/tests/plugins/CLIReporterPlugin.test.ts new file mode 100644 index 0000000..aa72597 --- /dev/null +++ b/tests/plugins/CLIReporterPlugin.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { CLIReporterPlugin } from "../../src/plugins/CLIReporterPlugin.js"; +import { EventBus } from "../../src/EventBus.js"; +import { PluginContext } from "../../src/contracts/Plugin.js"; +import { TaskStep } from "../../src/TaskStep.js"; +import { TaskResult } from "../../src/TaskResult.js"; + +describe("CLIReporterPlugin", () => { + let plugin: CLIReporterPlugin; + let events: EventBus; + let context: PluginContext; + let consoleLogSpy: ReturnType; + + beforeEach(() => { + plugin = new CLIReporterPlugin(); + events = new EventBus(); + context = { events }; + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + it("should have correct name and version", () => { + expect(plugin.name).toBe("cli-reporter"); + expect(plugin.version).toBe("1.0.0"); + }); + + it("should output workflow start", async () => { + plugin.install(context); + events.emit("workflowStart", { context: {}, steps: [] }); + // events are processed asynchronously + await new Promise(process.nextTick); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Starting TaskRunner workflow")); + }); + + it("should output task start", async () => { + plugin.install(context); + const step: TaskStep = { name: "Step1", run: async () => ({ status: "success" }) }; + events.emit("taskStart", { step }); + await new Promise(process.nextTick); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Starting: Step1")); + }); + + it("should output task success", async () => { + plugin.install(context); + const step: TaskStep = { name: "Step1", run: async () => ({ status: "success" }) }; + const result: TaskResult = { status: "success" }; + events.emit("taskEnd", { step, result }); + await new Promise(process.nextTick); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Success: Step1")); + }); + + it("should output task failure", async () => { + plugin.install(context); + const step: TaskStep = { name: "Step1", run: async () => ({ status: "failure" }) }; + const result: TaskResult = { status: "failure", error: "Oops" }; + events.emit("taskEnd", { step, result }); + await new Promise(process.nextTick); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Failed: Step1 - Oops")); + }); + + it("should output task skip", async () => { + plugin.install(context); + const step: TaskStep = { name: "Step1", run: async () => ({ status: "success" }) }; + const result: TaskResult = { status: "skipped" }; + events.emit("taskSkipped", { step, result }); + await new Promise(process.nextTick); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Skipped: Step1")); + }); + + it("should output task skip with error", async () => { + plugin.install(context); + const step: TaskStep = { name: "Step1", run: async () => ({ status: "success" }) }; + const result: TaskResult = { status: "skipped", error: "Condition failed" }; + events.emit("taskSkipped", { step, result }); + await new Promise(process.nextTick); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Skipped: Step1 - Condition failed")); + }); + + it("should output summary at workflow end", async () => { + plugin.install(context); + events.emit("workflowStart", { context: {}, steps: [] }); + + const step1: TaskStep = { name: "Step1", run: async () => ({ status: "success" }) }; + events.emit("taskEnd", { step: step1, result: { status: "success" } }); + + const step2: TaskStep = { name: "Step2", run: async () => ({ status: "failure" }) }; + events.emit("taskEnd", { step: step2, result: { status: "failure" } }); + + const step3: TaskStep = { name: "Step3", run: async () => ({ status: "success" }) }; + events.emit("taskSkipped", { step: step3, result: { status: "skipped" } }); + + events.emit("workflowEnd", { context: {}, results: new Map() }); + + await new Promise(process.nextTick); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Summary:")); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Success: 1")); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Failed: 1")); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Skipped: 1")); + }); +});