Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 0 additions & 8 deletions openspec/changes/add-cli-reporter/tasks.md

This file was deleted.

8 changes: 8 additions & 0 deletions openspec/changes/archive/2026-04-03-add-cli-reporter/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
## 1. Implementation

- [x] 1.1 Create `src/plugins/CLIReporterPlugin.ts` implementing `Plugin<TContext>`.
- [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.
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
53 changes: 53 additions & 0 deletions src/plugins/CLIReporterPlugin.ts
Original file line number Diff line number Diff line change
@@ -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<TContext> implements Plugin<TContext> {
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<TContext>): 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}` : ""}`);

Check warning on line 34 in src/plugins/CLIReporterPlugin.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not use nested template literals.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q0ZR3Ih8m09xZbDpv&open=AZ1Q0ZR3Ih8m09xZbDpv&pullRequest=241
}
});

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`);
});
}
Comment on lines +10 to +52
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The plugin currently stores execution state as class members. To prevent stale data across multiple initializations or concurrent executions, ensure all state-holding members are cleared or move state into the method scope. This aligns with the repository rule regarding stateful class initialization.

  public install(context: PluginContext<TContext>): void {
    let startTime = 0;

    context.events.on("workflowStart", () => {
      startTime = performance.now();
      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") {
        console.log(`✅ Success: ${step.name}`);
      } else {
        console.log(`❌ Failed:  ${step.name}${result.error ? ` - ${result.error}` : ""}`);
      }
    });

    context.events.on("taskSkipped", ({ step, result }) => {
      const reason = result.error ? ` - ${result.error}` : "";
      console.log(`⏭️  Skipped: ${step.name}${reason}`);
    });

    context.events.on("workflowEnd", ({ results }) => {
      const duration = performance.now() - startTime;
      let successCount = 0;
      let failureCount = 0;
      let skippedCount = 0;

      for (const result of results.values()) {
        if (result.status === "success") successCount++;
        else if (result.status === "failure") failureCount++;
        else if (result.status === "skipped") skippedCount++;
      }

      console.log(`\n🏁 Workflow Completed in ${duration.toFixed(2)}ms`);
      console.log("📊 Summary:");
      console.log(`   ✅ Success: ${successCount}`);
      console.log(`   ❌ Failed:  ${failureCount}`);
      console.log(`   ⏭️  Skipped: ${skippedCount}\n`);
    });
  }
References
  1. When implementing an initialize or reset method in a stateful class, ensure all state-holding members are cleared to prevent stale data across multiple initializations.

}
106 changes: 106 additions & 0 deletions tests/plugins/CLIReporterPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
let events: EventBus<unknown>;
let context: PluginContext<unknown>;
let consoleLogSpy: ReturnType<typeof vi.spyOn>;

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using process.nextTick in tests makes them dependent on the Node.js environment. Since the EventBus uses queueMicrotask, it is better to use await Promise.resolve() to wait for the microtask queue to flush. This improves test portability.

Suggested change
await new Promise(process.nextTick);
await Promise.resolve();


expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Starting TaskRunner workflow"));
});

it("should output task start", async () => {
plugin.install(context);
const step: TaskStep<unknown> = { 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<unknown> = { 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<unknown> = { 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<unknown> = { 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<unknown> = { 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<unknown> = { name: "Step1", run: async () => ({ status: "success" }) };
events.emit("taskEnd", { step: step1, result: { status: "success" } });

const step2: TaskStep<unknown> = { name: "Step2", run: async () => ({ status: "failure" }) };
events.emit("taskEnd", { step: step2, result: { status: "failure" } });

const step3: TaskStep<unknown> = { 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"));
});
});
Loading