From fe005a7cd369d93c6ca417e5f8425bb4d13e8de6 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Mon, 11 May 2026 20:21:27 +0200 Subject: [PATCH] feat: add comprehensive e2e tests for all dashboard features Expand from 2 to 42 e2e tests covering stats bar, daily/model charts, session card details, recency styling, directory filter, /api/directories endpoint, subagent aggregation, empty state, auto-refresh, and two-column layout. Enrich seed data with multiple sessions, models, plan/build modes, child sessions, and multi-day usage history. --- tests/e2e/it_aggregates_subagents.spec.ts | 53 ++++++ tests/e2e/it_applies_recency_styling.spec.ts | 31 ++++ tests/e2e/it_auto_refreshes.spec.ts | 37 ++++ tests/e2e/it_filters_by_directory.spec.ts | 73 ++++++++ tests/e2e/it_renders_daily_chart.spec.ts | 75 ++++++++ tests/e2e/it_renders_model_chart.spec.ts | 48 +++++ .../it_renders_session_card_details.spec.ts | 81 ++++++++ tests/e2e/it_renders_stats_bar.spec.ts | 45 +++++ .../e2e/it_renders_two_column_layout.spec.ts | 41 +++++ tests/e2e/it_returns_directories_api.spec.ts | 30 +++ tests/e2e/it_shows_empty_state.spec.ts | 85 +++++++++ tests/e2e/seed-db.ts | 174 +++++++++++++++++- tests/e2e/seed-empty-db.ts | 76 ++++++++ 13 files changed, 841 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/it_aggregates_subagents.spec.ts create mode 100644 tests/e2e/it_applies_recency_styling.spec.ts create mode 100644 tests/e2e/it_auto_refreshes.spec.ts create mode 100644 tests/e2e/it_filters_by_directory.spec.ts create mode 100644 tests/e2e/it_renders_daily_chart.spec.ts create mode 100644 tests/e2e/it_renders_model_chart.spec.ts create mode 100644 tests/e2e/it_renders_session_card_details.spec.ts create mode 100644 tests/e2e/it_renders_stats_bar.spec.ts create mode 100644 tests/e2e/it_renders_two_column_layout.spec.ts create mode 100644 tests/e2e/it_returns_directories_api.spec.ts create mode 100644 tests/e2e/it_shows_empty_state.spec.ts create mode 100644 tests/e2e/seed-empty-db.ts diff --git a/tests/e2e/it_aggregates_subagents.spec.ts b/tests/e2e/it_aggregates_subagents.spec.ts new file mode 100644 index 0000000..e95162b --- /dev/null +++ b/tests/e2e/it_aggregates_subagents.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test"; + +test.describe("subagent aggregation", () => { + test("child session is not rendered as a separate session card", async ({ + page, + }) => { + await page.goto("/"); + + // The child session has title "@explore subagent" - it should NOT appear as its own card + const cards = page.locator(".session-card"); + const count = await cards.count(); + + // We seeded 2 root sessions + 1 child. Only 2 cards should render. + expect(count).toBe(2); + + // Verify the child title does not appear as a standalone card title + await expect( + page.locator(".session-title", { hasText: "@explore subagent" }), + ).not.toBeVisible(); + }); + + test("parent session card shows agent row from subagent", async ({ + page, + }) => { + await page.goto("/"); + + const parentCard = page.locator(".session-card", { + hasText: "E2E Session", + }); + + // The child session title "@explore subagent" should produce an "explore" agent badge + await expect( + parentCard.locator(".agent-badge", { hasText: "explore" }), + ).toBeVisible(); + }); + + test("parent session tokens include child session tokens", async ({ + page, + }) => { + await page.goto("/"); + + const parentCard = page.locator(".session-card", { + hasText: "E2E Session", + }); + const tokenIn = parentCard.locator(".session-tokens .token-in"); + await expect(tokenIn).toBeVisible(); + const text = await tokenIn.textContent(); + + // Parent input=2000, cache=1000 + child input=400, cache=200 + // renderTokens shows totalIn = input+cache = 2400+1200 = 3600 => "3.6k in" + expect(text).toContain("3.6k"); + }); +}); diff --git a/tests/e2e/it_applies_recency_styling.spec.ts b/tests/e2e/it_applies_recency_styling.spec.ts new file mode 100644 index 0000000..1a12162 --- /dev/null +++ b/tests/e2e/it_applies_recency_styling.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; + +test.describe("session recency styling", () => { + test("active session (last seen < 5 min ago) has active class", async ({ + page, + }) => { + await page.goto("/"); + + // session-e2e-1 was last seen 2 min ago + const activeCard = page.locator(".session-card", { + hasText: "E2E Session", + }); + await expect(activeCard).toBeVisible(); + await expect(activeCard).toHaveClass(/session-card--active/); + }); + + test("old session (last seen > 24h ago) has no recency class", async ({ + page, + }) => { + await page.goto("/"); + + // session-e2e-2 was last seen 25 hours ago (> 24h = no class applied) + const oldCard = page.locator(".session-card", { hasText: "Other Session" }); + await expect(oldCard).toBeVisible(); + + const classList = await oldCard.getAttribute("class"); + expect(classList).not.toMatch( + /session-card--(active|recent|idle|stale|old)/, + ); + }); +}); diff --git a/tests/e2e/it_auto_refreshes.spec.ts b/tests/e2e/it_auto_refreshes.spec.ts new file mode 100644 index 0000000..25ef67b --- /dev/null +++ b/tests/e2e/it_auto_refreshes.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; + +test.describe("auto-refresh", () => { + test("page contains auto-refresh indicator", async ({ page }) => { + await page.goto("/"); + + await expect(page.locator(".refresh-badge")).toBeVisible(); + await expect(page.getByText("auto-refresh 5s")).toBeVisible(); + }); + + test("auto-refresh fetches /api/stats periodically", async ({ page }) => { + await page.goto("/"); + + // Wait for an auto-refresh fetch to /api/stats + const response = await page.waitForResponse( + (resp) => resp.url().includes("/api/stats") && resp.status() === 200, + { timeout: 10_000 }, + ); + + expect(response.ok()).toBe(true); + }); + + test("refresh timing indicator updates after refresh", async ({ page }) => { + await page.goto("/"); + + // Wait for a refresh cycle + await page.waitForResponse((resp) => resp.url().includes("/api/stats"), { + timeout: 10_000, + }); + + const timing = page.locator("#refresh-timing"); + await expect(timing).not.toBeEmpty(); + + const text = await timing.textContent(); + expect(text).toMatch(/took \d+ms/); + }); +}); diff --git a/tests/e2e/it_filters_by_directory.spec.ts b/tests/e2e/it_filters_by_directory.spec.ts new file mode 100644 index 0000000..b27180d --- /dev/null +++ b/tests/e2e/it_filters_by_directory.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from "@playwright/test"; + +test.describe("directory filter", () => { + test("renders directory filter dropdown", async ({ page }) => { + await page.goto("/"); + + const dropdown = page.locator("#dir-filter"); + await expect(dropdown).toBeVisible(); + }); + + test("dropdown has 'All directories' as default option", async ({ page }) => { + await page.goto("/"); + + const defaultOption = page.locator("#dir-filter option[value='']"); + await expect(defaultOption).toHaveText("All directories"); + }); + + test("dropdown lists seeded directories", async ({ page }) => { + await page.goto("/"); + + const dropdown = page.locator("#dir-filter"); + const options = dropdown.locator("option"); + const count = await options.count(); + + // "All directories" + at least 2 seeded dirs + expect(count).toBeGreaterThanOrEqual(3); + + const texts = await options.allTextContents(); + expect(texts).toContain("/tmp/e2e-project"); + expect(texts).toContain("/tmp/e2e-other"); + }); + + test("selecting a directory filters sessions", async ({ page }) => { + await page.goto("/"); + + // Initially both sessions visible + await expect( + page.locator(".session-card", { hasText: "E2E Session" }), + ).toBeVisible(); + await expect( + page.locator(".session-card", { hasText: "Other Session" }), + ).toBeVisible(); + + // Select /tmp/e2e-other + await page.locator("#dir-filter").selectOption("/tmp/e2e-other"); + + // Wait for refresh to apply the filter + await page.waitForResponse((resp) => + resp.url().includes("/api/stats?dir="), + ); + + // Only "Other Session" should be visible + await expect( + page.locator(".session-card", { hasText: "Other Session" }), + ).toBeVisible(); + await expect( + page.locator(".session-card", { hasText: "E2E Session" }), + ).not.toBeVisible(); + }); + + test("page loaded with ?dir= query param filters sessions", async ({ + page, + }) => { + await page.goto("/?dir=/tmp/e2e-other"); + + await expect( + page.locator(".session-card", { hasText: "Other Session" }), + ).toBeVisible(); + await expect( + page.locator(".session-card", { hasText: "E2E Session" }), + ).not.toBeVisible(); + }); +}); diff --git a/tests/e2e/it_renders_daily_chart.spec.ts b/tests/e2e/it_renders_daily_chart.spec.ts new file mode 100644 index 0000000..1fb6d62 --- /dev/null +++ b/tests/e2e/it_renders_daily_chart.spec.ts @@ -0,0 +1,75 @@ +import { expect, test } from "@playwright/test"; + +test.describe("daily chart", () => { + test("renders daily token usage chart with title", async ({ page }) => { + await page.goto("/"); + + const chart = page.locator(".daily-chart").first(); + await expect(chart).toBeVisible(); + await expect( + chart.locator(".chart-title", { + hasText: "Daily Token Usage (last 60 days)", + }), + ).toBeVisible(); + }); + + test("renders bar columns in chart container", async ({ page }) => { + await page.goto("/"); + + const container = page + .locator(".daily-chart") + .first() + .locator(".chart-container"); + await expect(container).toBeVisible(); + + const cols = container.locator(".chart-col"); + // 60 days of bars + await expect(cols).toHaveCount(60); + }); + + test("renders 5-day moving average SVG polyline", async ({ page }) => { + await page.goto("/"); + + const svg = page + .locator(".daily-chart") + .first() + .locator("svg.chart-avg-line"); + await expect(svg).toBeVisible(); + + const polyline = svg.locator("polyline"); + await expect(polyline).toHaveCount(1); + + const points = await polyline.getAttribute("points"); + expect(points).toBeTruthy(); + expect(points!.split(" ").length).toBe(60); + }); + + test("renders chart legend with daily tokens and 5-day avg", async ({ + page, + }) => { + await page.goto("/"); + + const legend = page + .locator(".daily-chart") + .first() + .locator(".chart-legend"); + await expect( + legend.locator(".legend-item", { hasText: "Daily tokens" }), + ).toBeVisible(); + await expect( + legend.locator(".legend-item", { hasText: "5-day avg" }), + ).toBeVisible(); + }); + + test("shows non-zero bars for seeded days", async ({ page }) => { + await page.goto("/"); + + // At least some bars should have height > 0 (non-empty style) + const barsWithHeight = page + .locator(".daily-chart") + .first() + .locator(".chart-bar[style*='height']"); + const count = await barsWithHeight.count(); + expect(count).toBeGreaterThan(0); + }); +}); diff --git a/tests/e2e/it_renders_model_chart.spec.ts b/tests/e2e/it_renders_model_chart.spec.ts new file mode 100644 index 0000000..8763320 --- /dev/null +++ b/tests/e2e/it_renders_model_chart.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from "@playwright/test"; + +test.describe("model chart", () => { + test("renders model chart with title", async ({ page }) => { + await page.goto("/"); + + // The model chart is the second .daily-chart + const modelChart = page.locator(".daily-chart").nth(1); + await expect(modelChart).toBeVisible(); + await expect( + modelChart.locator(".chart-title", { + hasText: "Daily Token Usage by Model", + }), + ).toBeVisible(); + }); + + test("renders 60 bar columns", async ({ page }) => { + await page.goto("/"); + + const modelChart = page.locator(".daily-chart").nth(1); + const cols = modelChart.locator(".chart-container .chart-col"); + await expect(cols).toHaveCount(60); + }); + + test("renders stacked bar segments for days with data", async ({ page }) => { + await page.goto("/"); + + const modelChart = page.locator(".daily-chart").nth(1); + const segments = modelChart.locator(".model-bar-seg"); + const count = await segments.count(); + expect(count).toBeGreaterThan(0); + }); + + test("renders legend with seeded model names", async ({ page }) => { + await page.goto("/"); + + const modelChart = page.locator(".daily-chart").nth(1); + const legend = modelChart.locator(".chart-legend"); + + // We seeded gpt-5.3-codex and claude-sonnet-4 + await expect( + legend.locator(".legend-item", { hasText: "gpt-5.3-codex" }), + ).toBeVisible(); + await expect( + legend.locator(".legend-item", { hasText: "claude-sonnet-4" }), + ).toBeVisible(); + }); +}); diff --git a/tests/e2e/it_renders_session_card_details.spec.ts b/tests/e2e/it_renders_session_card_details.spec.ts new file mode 100644 index 0000000..e24ba68 --- /dev/null +++ b/tests/e2e/it_renders_session_card_details.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from "@playwright/test"; + +test.describe("session card details", () => { + test("renders session title and directory", async ({ page }) => { + await page.goto("/"); + + const card = page.locator(".session-card", { hasText: "E2E Session" }); + await expect(card).toBeVisible(); + await expect( + card.locator(".session-title", { hasText: "E2E Session" }), + ).toBeVisible(); + await expect( + card.locator(".session-dir", { hasText: "/tmp/e2e-project" }), + ).toBeVisible(); + }); + + test("renders session ID", async ({ page }) => { + await page.goto("/"); + + const card = page.locator(".session-card", { hasText: "E2E Session" }); + await expect( + card.locator(".session-id", { hasText: "session-e2e-1" }), + ).toBeVisible(); + }); + + test("renders session-level token breakdown", async ({ page }) => { + await page.goto("/"); + + const card = page.locator(".session-card", { hasText: "E2E Session" }); + const tokenSection = card.locator(".session-tokens"); + await expect(tokenSection).toBeVisible(); + await expect( + tokenSection.locator(".token-label", { hasText: "Tokens:" }), + ).toBeVisible(); + // renderTokens outputs .token-in and .token-out spans directly (no wrapper) + await expect(tokenSection.locator(".token-in")).toBeVisible(); + await expect(tokenSection.locator(".token-out")).toBeVisible(); + }); + + test("renders mode rows with plan and build badges", async ({ page }) => { + await page.goto("/"); + + const card = page.locator(".session-card", { hasText: "E2E Session" }); + + // Session 1 has both build and plan messages + await expect( + card.locator(".mode-badge.mode-build", { hasText: "Build" }), + ).toBeVisible(); + await expect( + card.locator(".mode-badge.mode-plan", { hasText: "Plan" }), + ).toBeVisible(); + }); + + test("mode rows show message count and token details", async ({ page }) => { + await page.goto("/"); + + const card = page.locator(".session-card", { hasText: "E2E Session" }); + const modeRows = card.locator(".mode-row"); + const count = await modeRows.count(); + expect(count).toBeGreaterThanOrEqual(2); + + // Each mode row should have message count and token detail + for (let i = 0; i < count; i++) { + const row = modeRows.nth(i); + await expect(row.locator(".mode-msgs")).toBeVisible(); + await expect(row.locator(".tokens-detail")).toBeVisible(); + } + }); + + test("renders session timestamp", async ({ page }) => { + await page.goto("/"); + + const card = page.locator(".session-card", { hasText: "E2E Session" }); + const time = card.locator(".session-time"); + await expect(time).toBeVisible(); + + const text = await time.textContent(); + // Should contain a date-like string (YYYY-MM-DD HH:MM format) + expect(text).toMatch(/\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}/); + }); +}); diff --git a/tests/e2e/it_renders_stats_bar.spec.ts b/tests/e2e/it_renders_stats_bar.spec.ts new file mode 100644 index 0000000..d7d8f71 --- /dev/null +++ b/tests/e2e/it_renders_stats_bar.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; + +test.describe("stats bar", () => { + test("renders stats bar with token summary periods", async ({ page }) => { + await page.goto("/"); + + const statsBar = page.locator(".stats-bar"); + await expect(statsBar).toBeVisible(); + + await expect( + statsBar.locator(".stats-label", { hasText: "Today:" }), + ).toBeVisible(); + await expect( + statsBar.locator(".stats-label", { hasText: "This Week:" }), + ).toBeVisible(); + await expect( + statsBar.locator(".stats-label", { hasText: "This Month:" }), + ).toBeVisible(); + await expect( + statsBar.locator(".stats-label", { hasText: "Last Month:" }), + ).toBeVisible(); + }); + + test("displays non-zero token values for seeded data", async ({ page }) => { + await page.goto("/"); + + const values = page.locator(".stats-bar .stats-value"); + const count = await values.count(); + expect(count).toBe(4); + + // Today should show non-zero because we seeded messages with timestamps from today + const todayValue = await values.nth(0).textContent(); + expect(todayValue).not.toBe("0"); + }); + + test("shows Overall mode badge", async ({ page }) => { + await page.goto("/"); + + await expect( + page.locator(".stats-bar .mode-badge.mode-overall", { + hasText: "Overall", + }), + ).toBeVisible(); + }); +}); diff --git a/tests/e2e/it_renders_two_column_layout.spec.ts b/tests/e2e/it_renders_two_column_layout.spec.ts new file mode 100644 index 0000000..181cb4b --- /dev/null +++ b/tests/e2e/it_renders_two_column_layout.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from "@playwright/test"; + +test.describe("two-column layout", () => { + test("renders two-column container", async ({ page }) => { + await page.goto("/"); + + await expect(page.locator(".two-col")).toBeVisible(); + }); + + test("left panel contains stats bar and charts", async ({ page }) => { + await page.goto("/"); + + const leftPanel = page.locator(".left-panel"); + await expect(leftPanel).toBeVisible(); + + await expect(leftPanel.locator(".stats-bar")).toBeVisible(); + await expect(leftPanel.locator(".daily-chart")).toHaveCount(2); + }); + + test("right panel contains directory filter and session cards", async ({ + page, + }) => { + await page.goto("/"); + + const rightPanel = page.locator(".right-panel"); + await expect(rightPanel).toBeVisible(); + + await expect(rightPanel.locator("#dir-filter")).toBeVisible(); + await expect(rightPanel.locator(".session-card").first()).toBeVisible(); + }); + + test("left panel and right panel are siblings inside two-col", async ({ + page, + }) => { + await page.goto("/"); + + const twoCol = page.locator(".two-col"); + await expect(twoCol.locator("> .left-panel")).toBeVisible(); + await expect(twoCol.locator("> .right-panel")).toBeVisible(); + }); +}); diff --git a/tests/e2e/it_returns_directories_api.spec.ts b/tests/e2e/it_returns_directories_api.spec.ts new file mode 100644 index 0000000..550211e --- /dev/null +++ b/tests/e2e/it_returns_directories_api.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from "@playwright/test"; + +test.describe("/api/directories endpoint", () => { + test("returns JSON array of directories", async ({ request }) => { + const response = await request.get("/api/directories"); + expect(response.ok()).toBe(true); + + const contentType = response.headers()["content-type"]; + expect(contentType).toContain("application/json"); + + const dirs = await response.json(); + expect(Array.isArray(dirs)).toBe(true); + }); + + test("contains seeded directories", async ({ request }) => { + const response = await request.get("/api/directories"); + const dirs = await response.json(); + + expect(dirs).toContain("/tmp/e2e-project"); + expect(dirs).toContain("/tmp/e2e-other"); + }); + + test("does not contain duplicate entries", async ({ request }) => { + const response = await request.get("/api/directories"); + const dirs: string[] = await response.json(); + + const unique = new Set(dirs); + expect(unique.size).toBe(dirs.length); + }); +}); diff --git a/tests/e2e/it_shows_empty_state.spec.ts b/tests/e2e/it_shows_empty_state.spec.ts new file mode 100644 index 0000000..a9887d7 --- /dev/null +++ b/tests/e2e/it_shows_empty_state.spec.ts @@ -0,0 +1,85 @@ +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "@playwright/test"; + +const emptyDbPath = join( + tmpdir(), + "opencode", + `opencode-usage-stats-e2e-empty-${process.pid}.db`, +); +const emptyPort = 43435; + +test.describe("empty state", () => { + test.describe.configure({ mode: "serial" }); + + let serverProcess: import("node:child_process").ChildProcess; + + test.beforeAll(async () => { + const { execSync, spawn } = await import("node:child_process"); + + // Seed empty DB with unique path + execSync( + `OPENCODE_USAGE_STATS_DB=${emptyDbPath} bun run tests/e2e/seed-empty-db.ts`, + ); + + // Start dashboard on separate port + serverProcess = spawn("bun", ["run", "src/dashboard.ts"], { + env: { + ...process.env, + OPENCODE_USAGE_STATS_DB: emptyDbPath, + PORT: String(emptyPort), + }, + stdio: "pipe", + }); + + // Wait for server to be ready + const maxWait = 10_000; + const start = Date.now(); + while (Date.now() - start < maxWait) { + try { + const res = await fetch(`http://127.0.0.1:${emptyPort}/`); + if (res.ok) break; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 200)); + } + }); + + test.afterAll(async () => { + serverProcess?.kill(); + const { rmSync } = await import("node:fs"); + rmSync(emptyDbPath, { force: true }); + }); + + test("shows empty state message when no sessions exist", async ({ + browser, + }) => { + const context = await browser.newContext({ + baseURL: `http://127.0.0.1:${emptyPort}`, + }); + const page = await context.newPage(); + + await page.goto("/"); + + await expect( + page.locator(".empty", { hasText: "No sessions recorded yet." }), + ).toBeVisible(); + await expect(page.locator(".session-card")).toHaveCount(0); + + await context.close(); + }); + + test("stats bar still renders with zero values", async ({ browser }) => { + const context = await browser.newContext({ + baseURL: `http://127.0.0.1:${emptyPort}`, + }); + const page = await context.newPage(); + + await page.goto("/"); + + await expect(page.locator(".stats-bar")).toBeVisible(); + + await context.close(); + }); +}); diff --git a/tests/e2e/seed-db.ts b/tests/e2e/seed-db.ts index 9860bf9..56b3739 100644 --- a/tests/e2e/seed-db.ts +++ b/tests/e2e/seed-db.ts @@ -13,6 +13,8 @@ rmSync(dbPath, { force: true }); const db = new Database(dbPath); +// --- Schema --- + db.run(` CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, @@ -73,6 +75,27 @@ db.run(` ) `); +// --- Helper: date strings relative to now --- + +function daysAgo(n: number): string { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().slice(0, 10); +} + +function minutesAgo(n: number): string { + const d = new Date(); + d.setTime(d.getTime() - n * 60_000); + return d.toISOString().slice(0, 19).replace("T", " "); +} + +const today = daysAgo(0); +const yesterday = daysAgo(1); +const threeDaysAgo = daysAgo(3); + +// --- Sessions --- + +// Session 1: active session (last seen ~2 min ago) in /tmp/e2e-project db.prepare(` INSERT INTO sessions (session_id, title, directory, first_seen, last_seen) VALUES (?, ?, ?, ?, ?) @@ -80,19 +103,46 @@ db.prepare(` "session-e2e-1", "E2E Session", "/tmp/e2e-project", - "2026-05-10 10:00:00", - "2026-05-10 10:10:00", + minutesAgo(30), + minutesAgo(2), +); + +// Session 2: old session (25 hours ago) in /tmp/e2e-other +db.prepare(` + INSERT INTO sessions (session_id, title, directory, first_seen, last_seen) + VALUES (?, ?, ?, ?, ?) +`).run( + "session-e2e-2", + "Other Session", + "/tmp/e2e-other", + minutesAgo(25 * 60), + minutesAgo(25 * 60), +); + +// Session 3: child/subagent of session-e2e-1 (should NOT appear as own card) +db.prepare(` + INSERT INTO sessions (session_id, parent_id, title, directory, first_seen, last_seen) + VALUES (?, ?, ?, ?, ?, ?) +`).run( + "session-e2e-child-1", + "session-e2e-1", + "@explore subagent", + "/tmp/e2e-project", + minutesAgo(20), + minutesAgo(15), ); +// --- Messages --- + +// Session 1: build message with gpt-5.3-codex db.prepare(` INSERT INTO messages (timestamp, session_id, message_id, role, model_id, provider_id, input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, cost, agent) - VALUES - (?, ?, ?, 'assistant', ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, 'assistant', ?, ?, ?, ?, ?, ?, ?, ?) `).run( - "2026-05-10 10:05:00", + minutesAgo(25), "session-e2e-1", - "message-e2e-1", + "msg-e2e-1", "gpt-5.3-codex", "github-copilot", 1200, @@ -103,11 +153,71 @@ db.prepare(` "build", ); +// Session 1: plan message with claude-sonnet +db.prepare(` + INSERT INTO messages + (timestamp, session_id, message_id, role, model_id, provider_id, input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, cost, agent) + VALUES (?, ?, ?, 'assistant', ?, ?, ?, ?, ?, ?, ?, ?) +`).run( + minutesAgo(20), + "session-e2e-1", + "msg-e2e-2", + "claude-sonnet-4", + "anthropic", + 800, + 200, + 50, + 400, + 0.15, + "plan", +); + +// Session 2: build message with claude-sonnet (different model for model chart) +db.prepare(` + INSERT INTO messages + (timestamp, session_id, message_id, role, model_id, provider_id, input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, cost, agent) + VALUES (?, ?, ?, 'assistant', ?, ?, ?, ?, ?, ?, ?, ?) +`).run( + minutesAgo(25 * 60), + "session-e2e-2", + "msg-e2e-3", + "claude-sonnet-4", + "anthropic", + 500, + 150, + 0, + 200, + 0.1, + "build", +); + +// Child session message (subagent tokens) +db.prepare(` + INSERT INTO messages + (timestamp, session_id, message_id, role, model_id, provider_id, input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, cost, agent) + VALUES (?, ?, ?, 'assistant', ?, ?, ?, ?, ?, ?, ?, ?) +`).run( + minutesAgo(18), + "session-e2e-child-1", + "msg-e2e-child-1", + "claude-sonnet-4", + "anthropic", + 400, + 100, + 0, + 200, + 0.05, + "build", +); + +// --- Tool Calls --- + +// Session 1: bash tool call db.prepare(` INSERT INTO tool_calls (timestamp, session_id, call_id, tool_name, agent_type, description, agent, model_id, provider_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - "2026-05-10 10:06:00", + minutesAgo(24), "session-e2e-1", "call-e2e-1", "bash", @@ -118,9 +228,57 @@ db.prepare(` "github-copilot", ); +// Session 1: read tool call db.prepare(` + INSERT INTO tool_calls (timestamp, session_id, call_id, tool_name, agent_type, description, agent, model_id, provider_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +`).run( + minutesAgo(22), + "session-e2e-1", + "call-e2e-2", + "read", + "explore", + "Read file", + "build", + "claude-sonnet-4", + "anthropic", +); + +// Child session: tool call (explore subagent) +db.prepare(` + INSERT INTO tool_calls (timestamp, session_id, call_id, tool_name, agent_type, description, agent, model_id, provider_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +`).run( + minutesAgo(17), + "session-e2e-child-1", + "call-e2e-child-1", + "grep", + "explore", + "Search files", + "build", + "claude-sonnet-4", + "anthropic", +); + +// --- Daily Usage (multiple days for charts) --- + +const dailyData = [ + { day: today, tokens: 2500, sessions: 1, messages: 2, tools: 2 }, + { day: yesterday, tokens: 5000, sessions: 2, messages: 5, tools: 3 }, + { day: threeDaysAgo, tokens: 3200, sessions: 1, messages: 3, tools: 2 }, + { day: daysAgo(7), tokens: 8000, sessions: 3, messages: 8, tools: 6 }, + { day: daysAgo(14), tokens: 4500, sessions: 2, messages: 4, tools: 3 }, + { day: daysAgo(30), tokens: 6000, sessions: 2, messages: 6, tools: 4 }, + { day: daysAgo(45), tokens: 3000, sessions: 1, messages: 3, tools: 2 }, +]; + +const insertDaily = db.prepare(` INSERT INTO daily_usage (day, tokens_total, sessions_count, messages_count, tool_calls_count) VALUES (?, ?, ?, ?, ?) -`).run("2026-05-10", 2200, 1, 1, 1); +`); + +for (const d of dailyData) { + insertDaily.run(d.day, d.tokens, d.sessions, d.messages, d.tools); +} db.close(); diff --git a/tests/e2e/seed-empty-db.ts b/tests/e2e/seed-empty-db.ts new file mode 100644 index 0000000..59f770e --- /dev/null +++ b/tests/e2e/seed-empty-db.ts @@ -0,0 +1,76 @@ +import { Database } from "bun:sqlite"; +import { mkdirSync, rmSync } from "node:fs"; +import { dirname } from "node:path"; + +const dbPath = process.env.OPENCODE_USAGE_STATS_DB; + +if (!dbPath) { + throw new Error("OPENCODE_USAGE_STATS_DB is required"); +} + +mkdirSync(dirname(dbPath), { recursive: true }); +rmSync(dbPath, { force: true }); + +const db = new Database(dbPath); + +db.run(` + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + project_id TEXT, + parent_id TEXT, + title TEXT, + directory TEXT, + first_seen TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); + +db.run(` + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + session_id TEXT NOT NULL, + message_id TEXT NOT NULL, + role TEXT NOT NULL, + model_id TEXT, + provider_id TEXT, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_write_tokens INTEGER DEFAULT 0, + cost REAL DEFAULT 0, + agent TEXT, + UNIQUE(session_id, message_id) + ) +`); + +db.run(` + CREATE TABLE IF NOT EXISTS tool_calls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + session_id TEXT NOT NULL, + call_id TEXT NOT NULL UNIQUE, + tool_name TEXT NOT NULL, + agent_type TEXT, + description TEXT, + duration_ms INTEGER, + agent TEXT, + model_id TEXT, + provider_id TEXT + ) +`); + +db.run(` + CREATE TABLE IF NOT EXISTS daily_usage ( + day TEXT PRIMARY KEY, + tokens_total INTEGER NOT NULL DEFAULT 0, + cost_total REAL NOT NULL DEFAULT 0, + sessions_count INTEGER NOT NULL DEFAULT 0, + messages_count INTEGER NOT NULL DEFAULT 0, + tool_calls_count INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); + +db.close();