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.
96 lines
3.4 KiB
TypeScript
96 lines
3.4 KiB
TypeScript
/**
|
|
* @file QueryExplorer.test.tsx
|
|
* @description The Query Explorer page: it renders a page of rows with session
|
|
* links, sorts by an allowed column on a header click (flipping on a second),
|
|
* switches entity, and points both export links at the current filter.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import i18n from "i18next";
|
|
|
|
const { query } = vi.hoisted(() => ({
|
|
query: {
|
|
page: vi.fn(),
|
|
exportUrl: (p: URLSearchParams, f: string) => `/api/query?${p.toString()}&format=${f}`,
|
|
},
|
|
}));
|
|
vi.mock("../../lib/api", () => ({ api: { query } }));
|
|
vi.mock("../../hooks/useAiTools", () => ({
|
|
useAiTools: () => ({ tools: [{ id: "codex", label: "Codex" }] }),
|
|
}));
|
|
|
|
import { QueryExplorer } from "../QueryExplorer";
|
|
|
|
const page = (entity: string, rows: Record<string, string>[]) => ({
|
|
entity,
|
|
columns: Object.keys(rows[0] ?? {}),
|
|
sortable: entity === "sessions" ? ["started_at", "name"] : ["created_at"],
|
|
statuses: [],
|
|
rows,
|
|
total: rows.length,
|
|
limit: 50,
|
|
offset: 0,
|
|
export_cap: 50000,
|
|
});
|
|
const lastParams = () => {
|
|
const calls = query.page.mock.calls;
|
|
return calls[calls.length - 1]![0] as URLSearchParams;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
i18n.changeLanguage("en");
|
|
vi.clearAllMocks();
|
|
query.page.mockImplementation((p: URLSearchParams) =>
|
|
Promise.resolve(
|
|
p.get("entity") === "events"
|
|
? page("events", [{ id: "1", session_id: "sess-abcdef12", created_at: "2026-10-01" }])
|
|
: page("sessions", [{ id: "sess-abcdef12", name: "first", started_at: "2026-10-01" }])
|
|
)
|
|
);
|
|
});
|
|
|
|
describe("QueryExplorer", () => {
|
|
it("shows rows, sorts by an allowed column and exports the filter", async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<MemoryRouter>
|
|
<QueryExplorer />
|
|
</MemoryRouter>
|
|
);
|
|
expect(await screen.findByText("first")).toBeInTheDocument();
|
|
expect(screen.getByRole("link", { name: "sess-abc" })).toHaveAttribute(
|
|
"href",
|
|
"/sessions/sess-abcdef12"
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "name" }));
|
|
await waitFor(() => expect(lastParams().get("sort")).toBe("name"));
|
|
expect(lastParams().get("dir")).toBe("desc");
|
|
await user.click(screen.getByRole("button", { name: "name" }));
|
|
await waitFor(() => expect(lastParams().get("dir")).toBe("asc"));
|
|
// A column outside `sortable` has no sort button.
|
|
expect(screen.queryByRole("button", { name: "id" })).toBeNull();
|
|
const csv = screen.getByRole("link", { name: /Export CSV/ });
|
|
expect(csv.getAttribute("href")).toContain("sort=name");
|
|
expect(csv.getAttribute("href")).toContain("format=csv");
|
|
});
|
|
|
|
it("switches entity and drops the status filter for events", async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<MemoryRouter>
|
|
<QueryExplorer />
|
|
</MemoryRouter>
|
|
);
|
|
await screen.findByText("first");
|
|
expect(screen.getByRole("button", { name: /Status/ })).toBeInTheDocument();
|
|
await user.click(screen.getByRole("button", { name: /^Data/ }));
|
|
await user.click(screen.getByRole("option", { name: "Events" }));
|
|
await waitFor(() => expect(lastParams().get("entity")).toBe("events"));
|
|
expect(screen.queryByRole("button", { name: /Status/ })).toBeNull();
|
|
});
|
|
});
|