From eff2e618560e8805dab3ab94113b0dff8e72636b Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Wed, 18 Mar 2026 14:47:54 -0700 Subject: [PATCH 1/3] test: add 31 unit tests for estimate_cost helpers Export pure helper functions (estimateTokens, extractText, extractToolNames, formatTokens, formatCost, formatDuration, analyzeSessionFile) and add comprehensive tests covering: - Token estimation (empty, short, long strings) - Content extraction from string, block arrays, null/undefined - Tool name extraction from content blocks - Token/cost/duration formatting with edge cases - Session file analysis: prompt counting, correction detection, tool call counting, preflight detection, timestamp tracking, empty files, and malformed JSON resilience Brings test count from 43 to 74. --- src/tools/estimate-cost.ts | 16 +- tests/tools/estimate-cost.test.ts | 273 ++++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 tests/tools/estimate-cost.test.ts diff --git a/src/tools/estimate-cost.ts b/src/tools/estimate-cost.ts index 327491a..f7477c7 100644 --- a/src/tools/estimate-cost.ts +++ b/src/tools/estimate-cost.ts @@ -31,11 +31,11 @@ const PREFLIGHT_TOOLS = new Set([ // ── Helpers ───────────────────────────────────────────────────────────────── -function estimateTokens(text: string): number { +export function estimateTokens(text: string): number { return Math.ceil(text.length / 4); } -function extractText(content: unknown): string { +export function extractText(content: unknown): string { if (typeof content === "string") return content; if (Array.isArray(content)) { return content @@ -46,25 +46,25 @@ function extractText(content: unknown): string { return ""; } -function extractToolNames(content: unknown): string[] { +export function extractToolNames(content: unknown): string[] { if (!Array.isArray(content)) return []; return content .filter((b: any) => b.type === "tool_use" && b.name) .map((b: any) => b.name as string); } -function formatTokens(n: number): string { +export function formatTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; return String(n); } -function formatCost(dollars: number): string { +export function formatCost(dollars: number): string { if (dollars < 0.01) return `<$0.01`; return `$${dollars.toFixed(2)}`; } -function formatDuration(ms: number): string { +export function formatDuration(ms: number): string { const mins = Math.floor(ms / 60_000); if (mins < 60) return `${mins}m`; const hours = Math.floor(mins / 60); @@ -72,7 +72,7 @@ function formatDuration(ms: number): string { return `${hours}h ${rem}m`; } -interface SessionAnalysis { +export interface SessionAnalysis { inputTokens: number; outputTokens: number; promptCount: number; @@ -85,7 +85,7 @@ interface SessionAnalysis { lastTimestamp: string | null; } -function analyzeSessionFile(filePath: string): SessionAnalysis { +export function analyzeSessionFile(filePath: string): SessionAnalysis { const content = readFileSync(filePath, "utf-8"); const lines = content.trim().split("\n").filter(Boolean); diff --git a/tests/tools/estimate-cost.test.ts b/tests/tools/estimate-cost.test.ts new file mode 100644 index 0000000..8ee248f --- /dev/null +++ b/tests/tools/estimate-cost.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect } from "vitest"; +import { writeFileSync, mkdirSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { + estimateTokens, + extractText, + extractToolNames, + formatTokens, + formatCost, + formatDuration, + analyzeSessionFile, +} from "../../src/tools/estimate-cost.js"; + +// ── estimateTokens ────────────────────────────────────────────────────────── + +describe("estimateTokens", () => { + it("returns ~1 token per 4 chars", () => { + expect(estimateTokens("abcd")).toBe(1); + expect(estimateTokens("abcde")).toBe(2); // ceil(5/4) + }); + + it("handles empty string", () => { + expect(estimateTokens("")).toBe(0); + }); + + it("handles long text", () => { + const text = "x".repeat(1000); + expect(estimateTokens(text)).toBe(250); + }); +}); + +// ── extractText ───────────────────────────────────────────────────────────── + +describe("extractText", () => { + it("returns string content as-is", () => { + expect(extractText("hello world")).toBe("hello world"); + }); + + it("extracts text from content block array", () => { + const blocks = [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ]; + expect(extractText(blocks)).toBe("first\nsecond"); + }); + + it("skips non-text blocks", () => { + const blocks = [ + { type: "text", text: "keep" }, + { type: "tool_use", name: "read", input: {} }, + { type: "text", text: "also keep" }, + ]; + expect(extractText(blocks)).toBe("keep\nalso keep"); + }); + + it("returns empty string for null/undefined", () => { + expect(extractText(null)).toBe(""); + expect(extractText(undefined)).toBe(""); + }); + + it("returns empty string for number", () => { + expect(extractText(42)).toBe(""); + }); + + it("returns empty string for object", () => { + expect(extractText({ foo: "bar" })).toBe(""); + }); + + it("handles empty array", () => { + expect(extractText([])).toBe(""); + }); +}); + +// ── extractToolNames ──────────────────────────────────────────────────────── + +describe("extractToolNames", () => { + it("extracts tool names from content blocks", () => { + const blocks = [ + { type: "text", text: "I'll read the file" }, + { type: "tool_use", name: "Read", input: { path: "foo.ts" } }, + { type: "tool_use", name: "Edit", input: { path: "bar.ts" } }, + ]; + expect(extractToolNames(blocks)).toEqual(["Read", "Edit"]); + }); + + it("returns empty for non-array", () => { + expect(extractToolNames("string")).toEqual([]); + expect(extractToolNames(null)).toEqual([]); + expect(extractToolNames(42)).toEqual([]); + }); + + it("skips blocks without name", () => { + const blocks = [ + { type: "tool_use" }, + { type: "tool_use", name: "Read", input: {} }, + ]; + expect(extractToolNames(blocks)).toEqual(["Read"]); + }); + + it("handles empty array", () => { + expect(extractToolNames([])).toEqual([]); + }); +}); + +// ── formatTokens ──────────────────────────────────────────────────────────── + +describe("formatTokens", () => { + it("formats small numbers as-is", () => { + expect(formatTokens(500)).toBe("500"); + }); + + it("formats thousands as k", () => { + expect(formatTokens(1000)).toBe("1.0k"); + expect(formatTokens(15_500)).toBe("15.5k"); + }); + + it("formats millions as M", () => { + expect(formatTokens(1_000_000)).toBe("1.0M"); + expect(formatTokens(2_500_000)).toBe("2.5M"); + }); + + it("formats zero", () => { + expect(formatTokens(0)).toBe("0"); + }); +}); + +// ── formatCost ────────────────────────────────────────────────────────────── + +describe("formatCost", () => { + it("formats normal costs", () => { + expect(formatCost(1.5)).toBe("$1.50"); + expect(formatCost(0.05)).toBe("$0.05"); + }); + + it("formats very small costs", () => { + expect(formatCost(0.001)).toBe("<$0.01"); + expect(formatCost(0.009)).toBe("<$0.01"); + }); + + it("formats zero", () => { + expect(formatCost(0)).toBe("<$0.01"); + }); +}); + +// ── formatDuration ────────────────────────────────────────────────────────── + +describe("formatDuration", () => { + it("formats minutes", () => { + expect(formatDuration(5 * 60_000)).toBe("5m"); + expect(formatDuration(45 * 60_000)).toBe("45m"); + }); + + it("formats hours and minutes", () => { + expect(formatDuration(90 * 60_000)).toBe("1h 30m"); + expect(formatDuration(125 * 60_000)).toBe("2h 5m"); + }); + + it("formats zero", () => { + expect(formatDuration(0)).toBe("0m"); + }); +}); + +// ── analyzeSessionFile ────────────────────────────────────────────────────── + +describe("analyzeSessionFile", () => { + const tmpDir = join(tmpdir(), "preflight-test-estimate-cost"); + + function writeSession(name: string, lines: object[]): string { + mkdirSync(tmpDir, { recursive: true }); + const path = join(tmpDir, name); + writeFileSync(path, lines.map((l) => JSON.stringify(l)).join("\n")); + return path; + } + + afterAll(() => { + try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} + }); + + it("counts user prompts and assistant responses", () => { + const path = writeSession("basic.jsonl", [ + { type: "user", timestamp: "2025-01-01T00:00:00Z", message: { content: "hello world" } }, + { type: "assistant", timestamp: "2025-01-01T00:01:00Z", message: { content: "hi there, how can I help?" } }, + { type: "user", timestamp: "2025-01-01T00:02:00Z", message: { content: "do something" } }, + { type: "assistant", timestamp: "2025-01-01T00:03:00Z", message: { content: "done!" } }, + ]); + const result = analyzeSessionFile(path); + expect(result.promptCount).toBe(2); + expect(result.inputTokens).toBeGreaterThan(0); + expect(result.outputTokens).toBeGreaterThan(0); + }); + + it("detects corrections", () => { + const path = writeSession("corrections.jsonl", [ + { type: "user", timestamp: "2025-01-01T00:00:00Z", message: { content: "add a button" } }, + { type: "assistant", timestamp: "2025-01-01T00:01:00Z", message: { content: "I added a red button to the header component." } }, + { type: "user", timestamp: "2025-01-01T00:02:00Z", message: { content: "no, wrong file. I meant the footer." } }, + ]); + const result = analyzeSessionFile(path); + expect(result.corrections).toBe(1); + expect(result.wastedOutputTokens).toBeGreaterThan(0); + }); + + it("counts tool calls", () => { + const path = writeSession("tools.jsonl", [ + { type: "user", timestamp: "2025-01-01T00:00:00Z", message: { content: "read the file" } }, + { + type: "assistant", + timestamp: "2025-01-01T00:01:00Z", + message: { + content: [ + { type: "text", text: "I'll read the file." }, + { type: "tool_use", name: "Read", id: "t1", input: { path: "foo.ts" } }, + ], + }, + }, + { type: "tool_result", tool_use_id: "t1", content: "file contents here" }, + ]); + const result = analyzeSessionFile(path); + expect(result.toolCallCount).toBe(1); + }); + + it("detects preflight tool calls", () => { + const path = writeSession("preflight.jsonl", [ + { type: "user", timestamp: "2025-01-01T00:00:00Z", message: { content: "check my code" } }, + { + type: "assistant", + timestamp: "2025-01-01T00:01:00Z", + message: { + content: [ + { type: "text", text: "Running preflight check." }, + { type: "tool_use", name: "preflight_check", id: "t1", input: { task: "review code" } }, + ], + }, + }, + ]); + const result = analyzeSessionFile(path); + expect(result.preflightCalls).toBe(1); + expect(result.preflightTokens).toBeGreaterThan(0); + }); + + it("tracks timestamps", () => { + const path = writeSession("timestamps.jsonl", [ + { type: "user", timestamp: "2025-01-01T10:00:00Z", message: { content: "start" } }, + { type: "assistant", timestamp: "2025-01-01T10:30:00Z", message: { content: "end" } }, + ]); + const result = analyzeSessionFile(path); + expect(result.firstTimestamp).toBe("2025-01-01T10:00:00Z"); + expect(result.lastTimestamp).toBe("2025-01-01T10:30:00Z"); + }); + + it("handles empty file", () => { + const path = writeSession("empty.jsonl", []); + writeFileSync(path, ""); + const result = analyzeSessionFile(path); + expect(result.promptCount).toBe(0); + expect(result.inputTokens).toBe(0); + expect(result.outputTokens).toBe(0); + }); + + it("skips malformed JSON lines", () => { + const path = join(tmpDir, "malformed.jsonl"); + mkdirSync(tmpDir, { recursive: true }); + writeFileSync(path, [ + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"content":"hello"}}', + "not valid json", + '{"type":"assistant","timestamp":"2025-01-01T00:01:00Z","message":{"content":"hi"}}', + ].join("\n")); + const result = analyzeSessionFile(path); + expect(result.promptCount).toBe(1); + expect(result.outputTokens).toBeGreaterThan(0); + }); +}); From b054ee178c20f0bd8aeb1a48a5ca58055fad0f38 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Wed, 18 Mar 2026 15:15:28 -0700 Subject: [PATCH 2/3] feat: add export_report tool for markdown session reports Generates session reports from timeline data with: - Activity overview (events, prompts, commits, errors) - Prompt quality scoring (correction rate analysis) - Daily activity breakdown with visual bars - Event type distribution - Recent commits and errors - Optional file output Closes #5 --- src/index.ts | 2 + src/tools/export-report.ts | 313 +++++++++++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 src/tools/export-report.ts diff --git a/src/index.ts b/src/index.ts index e7e9d00..1528c44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,7 @@ import { registerScanSessions } from "./tools/scan-sessions.js"; import { registerGenerateScorecard } from "./tools/generate-scorecard.js"; import { registerSearchContracts } from "./tools/search-contracts.js"; import { registerEstimateCost } from "./tools/estimate-cost.js"; +import { registerExportReport } from "./tools/export-report.js"; // Validate related projects from config function validateRelatedProjects(): void { @@ -110,6 +111,7 @@ const toolRegistry: Array<[string, RegisterFn]> = [ ["generate_scorecard", registerGenerateScorecard], ["estimate_cost", registerEstimateCost], ["search_contracts", registerSearchContracts], + ["export_report", registerExportReport], ]; let registered = 0; diff --git a/src/tools/export-report.ts b/src/tools/export-report.ts new file mode 100644 index 0000000..9ec7155 --- /dev/null +++ b/src/tools/export-report.ts @@ -0,0 +1,313 @@ +// ============================================================================= +// export_report — Generate markdown session reports from timeline data +// Weekly summaries, prompt quality trends, activity breakdowns +// ============================================================================= + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getTimeline, listIndexedProjects } from "../lib/timeline-db.js"; +import { getRelatedProjects } from "../lib/config.js"; +import type { SearchScope } from "../types.js"; +import { writeFileSync, mkdirSync } from "fs"; +import { join, dirname } from "path"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function getDateRange(period: string): { since: string; until: string; label: string } { + const now = new Date(); + const until = now.toISOString(); + + switch (period) { + case "today": { + const start = new Date(now); + start.setHours(0, 0, 0, 0); + return { since: start.toISOString(), until, label: now.toISOString().slice(0, 10) }; + } + case "yesterday": { + const end = new Date(now); + end.setHours(0, 0, 0, 0); + const start = new Date(end); + start.setDate(start.getDate() - 1); + return { since: start.toISOString(), until: end.toISOString(), label: start.toISOString().slice(0, 10) }; + } + case "week": { + const start = new Date(now); + start.setDate(start.getDate() - 7); + return { + since: start.toISOString(), + until, + label: `${start.toISOString().slice(0, 10)} to ${now.toISOString().slice(0, 10)}`, + }; + } + case "month": { + const start = new Date(now); + start.setMonth(start.getMonth() - 1); + return { + since: start.toISOString(), + until, + label: `${start.toISOString().slice(0, 10)} to ${now.toISOString().slice(0, 10)}`, + }; + } + default: + throw new Error(`Unknown period: ${period}`); + } +} + +async function getSearchProjects(scope: SearchScope): Promise { + const currentProject = process.env.CLAUDE_PROJECT_DIR; + + switch (scope) { + case "current": + return currentProject ? [currentProject] : []; + case "related": { + const related = getRelatedProjects(); + return currentProject ? [currentProject, ...related] : related; + } + case "all": { + const projects = await listIndexedProjects(); + return projects.map((p) => p.project); + } + default: + return currentProject ? [currentProject] : []; + } +} + +interface EventSummary { + total: number; + byType: Record; + byDay: Record; + corrections: number; + errors: number; + commits: number; + prompts: number; + toolCalls: number; +} + +function summarizeEvents(events: any[]): EventSummary { + const summary: EventSummary = { + total: events.length, + byType: {}, + byDay: {}, + corrections: 0, + errors: 0, + commits: 0, + prompts: 0, + toolCalls: 0, + }; + + for (const e of events) { + // By type + summary.byType[e.type] = (summary.byType[e.type] || 0) + 1; + + // By day + const day = e.timestamp ? new Date(e.timestamp).toISOString().slice(0, 10) : "unknown"; + summary.byDay[day] = (summary.byDay[day] || 0) + 1; + + // Counts + if (e.type === "correction") summary.corrections++; + if (e.type === "error") summary.errors++; + if (e.type === "commit") summary.commits++; + if (e.type === "prompt") summary.prompts++; + if (e.type === "tool_call") summary.toolCalls++; + } + + return summary; +} + +function generateMarkdown( + summary: EventSummary, + label: string, + projectName: string, + events: any[], +): string { + const lines: string[] = []; + + lines.push(`# Session Report: ${projectName}`); + lines.push(`**Period:** ${label}`); + lines.push(`**Generated:** ${new Date().toISOString().slice(0, 19).replace("T", " ")} UTC`); + lines.push(""); + + // Overview + lines.push("## Overview"); + lines.push(""); + lines.push(`| Metric | Count |`); + lines.push(`|--------|-------|`); + lines.push(`| Total Events | ${summary.total} |`); + lines.push(`| Prompts | ${summary.prompts} |`); + lines.push(`| Tool Calls | ${summary.toolCalls} |`); + lines.push(`| Commits | ${summary.commits} |`); + lines.push(`| Corrections | ${summary.corrections} |`); + lines.push(`| Errors | ${summary.errors} |`); + lines.push(""); + + // Correction rate (prompt quality indicator) + if (summary.prompts > 0) { + const correctionRate = ((summary.corrections / summary.prompts) * 100).toFixed(1); + lines.push("## Prompt Quality"); + lines.push(""); + lines.push(`- **Correction rate:** ${correctionRate}% (${summary.corrections} corrections / ${summary.prompts} prompts)`); + const quality = + parseFloat(correctionRate) < 5 ? "🟢 Excellent" : + parseFloat(correctionRate) < 15 ? "🟡 Good" : + parseFloat(correctionRate) < 30 ? "🟠 Needs Improvement" : + "🔴 Poor"; + lines.push(`- **Quality:** ${quality}`); + lines.push(""); + } + + // Daily activity + const sortedDays = Object.keys(summary.byDay).sort(); + if (sortedDays.length > 1) { + lines.push("## Daily Activity"); + lines.push(""); + lines.push("| Date | Events |"); + lines.push("|------|--------|"); + for (const day of sortedDays) { + const count = summary.byDay[day]; + const bar = "█".repeat(Math.min(Math.ceil(count / 5), 20)); + lines.push(`| ${day} | ${count} ${bar} |`); + } + lines.push(""); + } + + // Event type breakdown + lines.push("## Event Breakdown"); + lines.push(""); + const typeIcons: Record = { + prompt: "💬", + assistant: "🤖", + tool_call: "🔧", + correction: "❌", + commit: "📦", + compaction: "🗜️", + sub_agent_spawn: "🚀", + error: "⚠️", + }; + for (const [type, count] of Object.entries(summary.byType).sort((a, b) => b[1] - a[1])) { + const icon = typeIcons[type] || "❓"; + const pct = ((count / summary.total) * 100).toFixed(1); + lines.push(`- ${icon} **${type}**: ${count} (${pct}%)`); + } + lines.push(""); + + // Recent commits + const recentCommits = events + .filter((e) => e.type === "commit") + .slice(-10); + if (recentCommits.length > 0) { + lines.push("## Recent Commits"); + lines.push(""); + for (const c of recentCommits) { + const hash = c.commit_hash ? c.commit_hash.slice(0, 7) : "???????"; + const msg = (c.content || c.summary || "").slice(0, 80).replace(/\n/g, " "); + const time = c.timestamp ? new Date(c.timestamp).toISOString().slice(0, 16).replace("T", " ") : ""; + lines.push(`- \`${hash}\` ${msg} _(${time})_`); + } + lines.push(""); + } + + // Recent errors + const recentErrors = events.filter((e) => e.type === "error").slice(-5); + if (recentErrors.length > 0) { + lines.push("## Recent Errors"); + lines.push(""); + for (const e of recentErrors) { + const msg = (e.content || "").slice(0, 120).replace(/\n/g, " "); + const time = e.timestamp ? new Date(e.timestamp).toISOString().slice(0, 16).replace("T", " ") : ""; + lines.push(`- ⚠️ ${msg} _(${time})_`); + } + lines.push(""); + } + + lines.push("---"); + lines.push("_Generated by [preflight](https://github.com/TerminalGravity/preflight) `export_report` tool_"); + + return lines.join("\n"); +} + +// ── Tool Registration ────────────────────────────────────────────────────── + +export function registerExportReport(server: McpServer) { + server.tool( + "export_report", + "Generate a markdown session report from timeline data. Shows activity summaries, prompt quality trends, daily breakdowns, and recent commits/errors.", + { + scope: z + .enum(["current", "related", "all"]) + .default("current") + .describe("Search scope: current project, related projects, or all indexed"), + project: z.string().optional().describe("Filter to a specific project (overrides scope)"), + period: z + .enum(["today", "yesterday", "week", "month"]) + .default("week") + .describe("Time period for the report"), + output: z + .string() + .optional() + .describe("File path to write the report to. If omitted, returns inline."), + }, + async (params) => { + const { since, until, label } = getDateRange(params.period); + + // Determine projects + let projectDirs: string[]; + if (params.project) { + projectDirs = [params.project]; + } else { + projectDirs = await getSearchProjects(params.scope); + } + + if (projectDirs.length === 0) { + return { + content: [ + { + type: "text" as const, + text: `No projects found for scope "${params.scope}". Set CLAUDE_PROJECT_DIR or onboard a project first.`, + }, + ], + }; + } + + // Fetch all events in range (high limit for reports) + const events = await getTimeline({ + project_dirs: projectDirs, + project: undefined, + since, + until, + limit: 5000, + offset: 0, + }); + + const projectName = params.project || (projectDirs.length === 1 ? projectDirs[0] : `${projectDirs.length} projects`); + const summary = summarizeEvents(events); + const markdown = generateMarkdown(summary, label, projectName, events); + + // Write to file if requested + if (params.output) { + try { + mkdirSync(dirname(params.output), { recursive: true }); + writeFileSync(params.output, markdown, "utf-8"); + return { + content: [ + { + type: "text" as const, + text: `Report written to ${params.output} (${summary.total} events, ${label})`, + }, + ], + }; + } catch (err: any) { + return { + content: [ + { + type: "text" as const, + text: `Failed to write report: ${err.message}\n\n${markdown}`, + }, + ], + }; + } + } + + return { content: [{ type: "text" as const, text: markdown }] }; + }, + ); +} From 2abb347ce0ed700a82e2979d65087ca1e5371a93 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Thu, 19 Mar 2026 07:15:51 -0700 Subject: [PATCH 3/3] test: add 18 unit tests for export_report helpers Export getDateRange, summarizeEvents, generateMarkdown, and EventSummary from export-report.ts and add comprehensive test coverage: - getDateRange: all periods + error case - summarizeEvents: empty, type counting, day grouping, missing timestamps - generateMarkdown: metrics table, correction rate quality tiers, commits/errors sections, daily activity, single vs multi-day Brings test count from 74 to 92. --- src/tools/export-report.ts | 8 +- tests/tools/export-report.test.ts | 181 ++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 tests/tools/export-report.test.ts diff --git a/src/tools/export-report.ts b/src/tools/export-report.ts index 9ec7155..fa0ffd9 100644 --- a/src/tools/export-report.ts +++ b/src/tools/export-report.ts @@ -13,7 +13,7 @@ import { join, dirname } from "path"; // ── Helpers ──────────────────────────────────────────────────────────────── -function getDateRange(period: string): { since: string; until: string; label: string } { +export function getDateRange(period: string): { since: string; until: string; label: string } { const now = new Date(); const until = now.toISOString(); @@ -72,7 +72,7 @@ async function getSearchProjects(scope: SearchScope): Promise { } } -interface EventSummary { +export interface EventSummary { total: number; byType: Record; byDay: Record; @@ -83,7 +83,7 @@ interface EventSummary { toolCalls: number; } -function summarizeEvents(events: any[]): EventSummary { +export function summarizeEvents(events: any[]): EventSummary { const summary: EventSummary = { total: events.length, byType: {}, @@ -114,7 +114,7 @@ function summarizeEvents(events: any[]): EventSummary { return summary; } -function generateMarkdown( +export function generateMarkdown( summary: EventSummary, label: string, projectName: string, diff --git a/tests/tools/export-report.test.ts b/tests/tools/export-report.test.ts new file mode 100644 index 0000000..eb2b507 --- /dev/null +++ b/tests/tools/export-report.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getDateRange, summarizeEvents, generateMarkdown } from "../../src/tools/export-report.js"; + +// ── getDateRange ─────────────────────────────────────────────────────────── + +describe("getDateRange", () => { + beforeEach(() => { + // Fix time to 2026-03-19T14:00:00.000Z + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-19T14:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns today range starting at midnight", () => { + const range = getDateRange("today"); + expect(range.since).toContain("2026-03-19"); + expect(range.until).toContain("2026-03-19"); + expect(range.label).toBe("2026-03-19"); + }); + + it("returns yesterday range", () => { + const range = getDateRange("yesterday"); + expect(range.since).toContain("2026-03-18"); + expect(range.label).toBe("2026-03-18"); + }); + + it("returns week range spanning 7 days", () => { + const range = getDateRange("week"); + expect(range.since).toContain("2026-03-12"); + expect(range.label).toContain("2026-03-12"); + expect(range.label).toContain("2026-03-19"); + }); + + it("returns month range", () => { + const range = getDateRange("month"); + expect(range.since).toContain("2026-02-19"); + expect(range.label).toContain("2026-02-19"); + }); + + it("throws on unknown period", () => { + expect(() => getDateRange("century")).toThrow("Unknown period: century"); + }); +}); + +// ── summarizeEvents ──────────────────────────────────────────────────────── + +describe("summarizeEvents", () => { + it("returns zero counts for empty array", () => { + const summary = summarizeEvents([]); + expect(summary.total).toBe(0); + expect(summary.corrections).toBe(0); + expect(summary.errors).toBe(0); + expect(summary.commits).toBe(0); + expect(summary.prompts).toBe(0); + expect(summary.toolCalls).toBe(0); + }); + + it("counts events by type correctly", () => { + const events = [ + { type: "prompt", timestamp: "2026-03-19T10:00:00Z" }, + { type: "prompt", timestamp: "2026-03-19T11:00:00Z" }, + { type: "tool_call", timestamp: "2026-03-19T10:30:00Z" }, + { type: "commit", timestamp: "2026-03-19T12:00:00Z" }, + { type: "correction", timestamp: "2026-03-19T12:30:00Z" }, + { type: "error", timestamp: "2026-03-19T13:00:00Z" }, + ]; + + const summary = summarizeEvents(events); + expect(summary.total).toBe(6); + expect(summary.prompts).toBe(2); + expect(summary.toolCalls).toBe(1); + expect(summary.commits).toBe(1); + expect(summary.corrections).toBe(1); + expect(summary.errors).toBe(1); + expect(summary.byType["prompt"]).toBe(2); + }); + + it("groups events by day", () => { + const events = [ + { type: "prompt", timestamp: "2026-03-18T10:00:00Z" }, + { type: "prompt", timestamp: "2026-03-18T15:00:00Z" }, + { type: "prompt", timestamp: "2026-03-19T10:00:00Z" }, + ]; + + const summary = summarizeEvents(events); + expect(summary.byDay["2026-03-18"]).toBe(2); + expect(summary.byDay["2026-03-19"]).toBe(1); + }); + + it("handles events without timestamps", () => { + const events = [{ type: "prompt" }]; + const summary = summarizeEvents(events); + expect(summary.byDay["unknown"]).toBe(1); + }); +}); + +// ── generateMarkdown ─────────────────────────────────────────────────────── + +describe("generateMarkdown", () => { + const baseSummary = { + total: 10, + byType: { prompt: 5, tool_call: 3, commit: 2 }, + byDay: { "2026-03-19": 10 }, + corrections: 1, + errors: 0, + commits: 2, + prompts: 5, + toolCalls: 3, + }; + + it("includes project name and period label", () => { + const md = generateMarkdown(baseSummary, "2026-03-19", "my-project", []); + expect(md).toContain("# Session Report: my-project"); + expect(md).toContain("**Period:** 2026-03-19"); + }); + + it("includes overview metrics table", () => { + const md = generateMarkdown(baseSummary, "week", "proj", []); + expect(md).toContain("| Total Events | 10 |"); + expect(md).toContain("| Prompts | 5 |"); + expect(md).toContain("| Commits | 2 |"); + }); + + it("calculates correction rate as prompt quality", () => { + const md = generateMarkdown(baseSummary, "week", "proj", []); + // 1 correction / 5 prompts = 20% + expect(md).toContain("20.0%"); + expect(md).toContain("Needs Improvement"); + }); + + it("shows excellent quality for low correction rate", () => { + const summary = { ...baseSummary, corrections: 0, prompts: 100 }; + const md = generateMarkdown(summary, "week", "proj", []); + expect(md).toContain("0.0%"); + expect(md).toContain("Excellent"); + }); + + it("includes recent commits section", () => { + const events = [ + { type: "commit", commit_hash: "abc1234567890", content: "fix: resolve timeout bug", timestamp: "2026-03-19T12:00:00Z" }, + ]; + const md = generateMarkdown(baseSummary, "week", "proj", events); + expect(md).toContain("## Recent Commits"); + expect(md).toContain("`abc1234`"); + expect(md).toContain("fix: resolve timeout bug"); + }); + + it("includes recent errors section", () => { + const events = [ + { type: "error", content: "ENOENT: file not found", timestamp: "2026-03-19T13:00:00Z" }, + ]; + const md = generateMarkdown({ ...baseSummary, errors: 1 }, "week", "proj", events); + expect(md).toContain("## Recent Errors"); + expect(md).toContain("ENOENT: file not found"); + }); + + it("skips daily activity table for single-day reports", () => { + const md = generateMarkdown(baseSummary, "today", "proj", []); + expect(md).not.toContain("## Daily Activity"); + }); + + it("shows daily activity table for multi-day reports", () => { + const summary = { + ...baseSummary, + byDay: { "2026-03-18": 4, "2026-03-19": 6 }, + }; + const md = generateMarkdown(summary, "week", "proj", []); + expect(md).toContain("## Daily Activity"); + expect(md).toContain("2026-03-18"); + expect(md).toContain("2026-03-19"); + }); + + it("includes preflight attribution footer", () => { + const md = generateMarkdown(baseSummary, "week", "proj", []); + expect(md).toContain("preflight"); + expect(md).toContain("export_report"); + }); +});