4aae61297f
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files
Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
94 lines
3.2 KiB
TypeScript
94 lines
3.2 KiB
TypeScript
/**
|
||
* @file EventReplay.test.tsx
|
||
* @description The Replay tab's controls: step, jump, arrow keys, a lifecycle
|
||
* marker jumping the cursor, playback advancing on a timer, and the derived
|
||
* state following the cursor.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||
import { MemoryRouter } from "react-router-dom";
|
||
import { EventReplay } from "../EventReplay";
|
||
import type { DashboardEvent } from "../../lib/types";
|
||
|
||
function ev(id: number, event_type: string, tool_name: string | null = null): DashboardEvent {
|
||
return {
|
||
id,
|
||
session_id: "s1",
|
||
agent_id: "a1",
|
||
event_type,
|
||
tool_name,
|
||
summary: `${event_type} ${tool_name ?? ""}`.trim(),
|
||
data: null,
|
||
created_at: new Date(Date.UTC(2026, 9, 1, 10, 0, id)).toISOString(),
|
||
} as DashboardEvent;
|
||
}
|
||
|
||
// Given newest first, as the events API answers: the replay orders them itself.
|
||
const events = [
|
||
ev(4, "Stop"),
|
||
ev(3, "PostToolUse", "Bash"),
|
||
ev(2, "PreToolUse", "Bash"),
|
||
ev(1, "SessionStart"),
|
||
];
|
||
|
||
function renderReplay() {
|
||
return render(
|
||
<MemoryRouter>
|
||
<EventReplay events={events} agentInfoById={new Map()} sessionNameById={new Map()} />
|
||
</MemoryRouter>
|
||
);
|
||
}
|
||
|
||
const position = () => screen.getByText(/^\d+ \/ 4/);
|
||
|
||
afterEach(() => vi.useRealTimers());
|
||
|
||
describe("EventReplay", () => {
|
||
it("starts at the first event and steps with the buttons", () => {
|
||
renderReplay();
|
||
expect(position().textContent).toMatch(/^1 \/ 4/);
|
||
fireEvent.click(screen.getByTitle("Step forward (→)"));
|
||
fireEvent.click(screen.getByTitle("Step forward (→)"));
|
||
expect(position().textContent).toMatch(/^3 \/ 4/);
|
||
expect(screen.getByText("Bash ×1")).toBeTruthy();
|
||
fireEvent.click(screen.getByTitle("Jump to end (End)"));
|
||
expect(position().textContent).toMatch(/^4 \/ 4/);
|
||
fireEvent.click(screen.getByTitle("Jump to start (Home)"));
|
||
expect(position().textContent).toMatch(/^1 \/ 4/);
|
||
});
|
||
|
||
it("moves on arrow keys and jumps from a lifecycle marker", () => {
|
||
renderReplay();
|
||
const root = screen.getByTestId("event-replay");
|
||
fireEvent.keyDown(root, { key: "ArrowRight" });
|
||
expect(position().textContent).toMatch(/^2 \/ 4/);
|
||
// The in-flight tool at the PreToolUse.
|
||
expect(screen.getByText("Running tool").nextSibling?.textContent).toBe("Bash");
|
||
fireEvent.keyDown(root, { key: "End" });
|
||
expect(position().textContent).toMatch(/^4 \/ 4/);
|
||
fireEvent.click(screen.getByRole("button", { name: "Session start · #1" }));
|
||
expect(position().textContent).toMatch(/^1 \/ 4/);
|
||
});
|
||
|
||
it("plays to the end at the chosen speed", () => {
|
||
vi.useFakeTimers();
|
||
renderReplay();
|
||
fireEvent.click(screen.getByRole("button", { name: "5×" }));
|
||
fireEvent.click(screen.getByTitle("Play (Space)"));
|
||
act(() => {
|
||
vi.advanceTimersByTime(100);
|
||
});
|
||
expect(position().textContent).toMatch(/^2 \/ 4/);
|
||
// One step per tick: each step schedules the next.
|
||
for (let i = 0; i < 3; i++) {
|
||
act(() => {
|
||
vi.advanceTimersByTime(100);
|
||
});
|
||
}
|
||
expect(position().textContent).toMatch(/^4 \/ 4/);
|
||
expect(screen.getByTitle("Play (Space)")).toBeTruthy();
|
||
});
|
||
});
|