diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 452150de4..c568cbe42 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -496,6 +496,47 @@ Update the Sentry CLI to the latest version - `--json - Output as JSON` - `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` +### Dashboard + +Manage Sentry dashboards + +#### `sentry dashboard list ` + +List dashboards + +**Flags:** +- `-w, --web - Open in browser` +- `-n, --limit - Maximum number of dashboards to list - (default: "30")` +- `-f, --fresh - Bypass cache and fetch fresh data` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + +#### `sentry dashboard view ` + +View a dashboard + +**Flags:** +- `-w, --web - Open in browser` +- `-f, --fresh - Bypass cache and fetch fresh data` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + +#### `sentry dashboard create ` + +Create a dashboard + +**Flags:** +- `--widget-title - Inline widget title` +- `--widget-display - Inline widget display type (line, bar, table, big_number, ...)` +- `--widget-dataset - Inline widget dataset (default: spans)` +- `--widget-query ... - Inline widget aggregate (e.g. count, p95:span.duration)` +- `--widget-where - Inline widget search conditions filter` +- `--widget-group-by ... - Inline widget group-by column (repeatable)` +- `--widget-sort - Inline widget order by (prefix - for desc)` +- `--widget-limit - Inline widget result limit` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + ### Repo Work with Sentry repositories @@ -701,6 +742,21 @@ Initialize Sentry in your project - `--features ... - Features to enable: errors,tracing,logs,replay,metrics` - `-t, --team - Team slug to create the project under` +### Dashboards + +List dashboards + +#### `sentry dashboards ` + +List dashboards + +**Flags:** +- `-w, --web - Open in browser` +- `-n, --limit - Maximum number of dashboards to list - (default: "30")` +- `-f, --fresh - Bypass cache and fetch fresh data` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + ### Issues List issues in a project diff --git a/src/app.ts b/src/app.ts index 231367d6a..782c872dd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,6 +11,8 @@ import { apiCommand } from "./commands/api.js"; import { authRoute } from "./commands/auth/index.js"; import { whoamiCommand } from "./commands/auth/whoami.js"; import { cliRoute } from "./commands/cli/index.js"; +import { dashboardRoute } from "./commands/dashboard/index.js"; +import { listCommand as dashboardListCommand } from "./commands/dashboard/list.js"; import { eventRoute } from "./commands/event/index.js"; import { helpCommand } from "./commands/help.js"; import { initCommand } from "./commands/init.js"; @@ -44,6 +46,7 @@ import { error as errorColor, warning } from "./lib/formatters/colors.js"; * Used to suggest the correct command when users type e.g. `sentry projects view cli`. */ const PLURAL_TO_SINGULAR: Record = { + dashboards: "dashboard", issues: "issue", orgs: "org", projects: "project", @@ -60,6 +63,7 @@ export const routes = buildRouteMap({ help: helpCommand, auth: authRoute, cli: cliRoute, + dashboard: dashboardRoute, org: orgRoute, project: projectRoute, repo: repoRoute, @@ -71,6 +75,7 @@ export const routes = buildRouteMap({ trial: trialRoute, init: initCommand, api: apiCommand, + dashboards: dashboardListCommand, issues: issueListCommand, orgs: orgListCommand, projects: projectListCommand, diff --git a/src/commands/dashboard/create.ts b/src/commands/dashboard/create.ts new file mode 100644 index 000000000..99c43c8dd --- /dev/null +++ b/src/commands/dashboard/create.ts @@ -0,0 +1,296 @@ +/** + * sentry dashboard create + * + * Create a new dashboard in a Sentry organization. + */ + +import type { SentryContext } from "../../context.js"; +import { createDashboard, getProject } from "../../lib/api-client.js"; +import { + type ParsedOrgProject, + parseOrgProjectArg, +} from "../../lib/arg-parsing.js"; +import { buildCommand, numberParser } from "../../lib/command.js"; +import { ContextError, ValidationError } from "../../lib/errors.js"; +import { formatDashboardCreated } from "../../lib/formatters/human.js"; +import { + fetchProjectId, + resolveAllTargets, + resolveOrg, + resolveProjectBySlug, + toNumericId, +} from "../../lib/resolve-target.js"; +import { buildDashboardUrl } from "../../lib/sentry-urls.js"; +import { + assignDefaultLayout, + type DashboardDetail, + type DashboardWidget, + DISPLAY_TYPES, + parseAggregate, + parseSortExpression, + parseWidgetInput, + prepareWidgetQueries, +} from "../../types/dashboard.js"; + +type CreateFlags = { + readonly "widget-title"?: string; + readonly "widget-display"?: string; + readonly "widget-dataset"?: string; + readonly "widget-query"?: string[]; + readonly "widget-where"?: string; + readonly "widget-group-by"?: string[]; + readonly "widget-sort"?: string; + readonly "widget-limit"?: number; + readonly json: boolean; + readonly fields?: string[]; +}; + +type CreateResult = DashboardDetail & { url: string }; + +/** + * Parse array positional args for `dashboard create`. + * + * Handles: + * - `` — title only (auto-detect org/project) + * - `<target> <title>` — explicit target + title + */ +function parsePositionalArgs(args: string[]): { + title: string; + targetArg: string | undefined; +} { + if (args.length === 0) { + throw new ValidationError("Dashboard title is required.", "title"); + } + if (args.length === 1) { + return { title: args[0] as string, targetArg: undefined }; + } + // Two args: first is target, second is title + return { title: args[1] as string, targetArg: args[0] as string }; +} + +/** Result of resolving org + project IDs from the parsed target */ +type ResolvedDashboardTarget = { + orgSlug: string; + projectIds: number[]; +}; + +/** Enrich targets that lack a projectId by calling the project API */ +async function enrichTargetProjectIds( + targets: { org: string; project: string; projectId?: number }[] +): Promise<number[]> { + const enriched = await Promise.all( + targets.map(async (t) => { + if (t.projectId !== undefined) { + return t.projectId; + } + try { + const info = await getProject(t.org, t.project); + return toNumericId(info.id); + } catch { + return; + } + }) + ); + return enriched.filter((id): id is number => id !== undefined); +} + +/** Resolve org and project IDs from the parsed target argument */ +async function resolveDashboardTarget( + parsed: ParsedOrgProject, + cwd: string +): Promise<ResolvedDashboardTarget> { + switch (parsed.type) { + case "explicit": { + const pid = await fetchProjectId(parsed.org, parsed.project); + return { + orgSlug: parsed.org, + projectIds: pid !== undefined ? [pid] : [], + }; + } + case "org-all": + return { orgSlug: parsed.org, projectIds: [] }; + + case "project-search": { + const found = await resolveProjectBySlug( + parsed.projectSlug, + "sentry dashboard create <org>/<project> <title>" + ); + const pid = await fetchProjectId(found.org, found.project); + return { + orgSlug: found.org, + projectIds: pid !== undefined ? [pid] : [], + }; + } + case "auto-detect": { + const result = await resolveAllTargets({ cwd }); + if (result.targets.length === 0) { + const resolved = await resolveOrg({ cwd }); + if (!resolved) { + throw new ContextError( + "Organization", + "sentry dashboard create <org>/ <title>" + ); + } + return { orgSlug: resolved.org, projectIds: [] }; + } + const orgSlug = (result.targets[0] as (typeof result.targets)[0]).org; + const projectIds = await enrichTargetProjectIds(result.targets); + return { orgSlug, projectIds }; + } + default: { + const _exhaustive: never = parsed; + throw new Error( + `Unexpected parsed type: ${(_exhaustive as { type: string }).type}` + ); + } + } +} + +/** Build an inline widget from --widget-* flags */ +function buildInlineWidget(flags: CreateFlags): DashboardWidget { + if (!flags["widget-title"]) { + throw new ValidationError( + "Missing --widget-title. Both --widget-title and --widget-display are required for inline widgets.\n\n" + + "Example:\n" + + " sentry dashboard create 'My Dashboard' --widget-title \"Error Count\" --widget-display big_number --widget-query count", + "widget-title" + ); + } + + const aggregates = (flags["widget-query"] ?? ["count"]).map(parseAggregate); + const columns = flags["widget-group-by"] ?? []; + const orderby = flags["widget-sort"] + ? parseSortExpression(flags["widget-sort"]) + : undefined; + + const rawWidget = { + title: flags["widget-title"], + displayType: flags["widget-display"] as string, + ...(flags["widget-dataset"] && { widgetType: flags["widget-dataset"] }), + queries: [ + { + aggregates, + columns, + conditions: flags["widget-where"] ?? "", + ...(orderby && { orderby }), + name: "", + }, + ], + ...(flags["widget-limit"] !== undefined && { + limit: flags["widget-limit"], + }), + }; + return prepareWidgetQueries(parseWidgetInput(rawWidget)); +} + +export const createCommand = buildCommand({ + docs: { + brief: "Create a dashboard", + fullDescription: + "Create a new Sentry dashboard.\n\n" + + "Examples:\n" + + " sentry dashboard create 'My Dashboard'\n" + + " sentry dashboard create my-org/ 'My Dashboard'\n" + + " sentry dashboard create my-org/my-project 'My Dashboard'\n\n" + + "With an inline widget:\n" + + " sentry dashboard create 'My Dashboard' \\\n" + + ' --widget-title "Error Count" --widget-display big_number --widget-query count', + }, + output: { + json: true, + human: formatDashboardCreated, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "[<org/project>] <title>", + parse: String, + }, + }, + flags: { + "widget-title": { + kind: "parsed", + parse: String, + brief: "Inline widget title", + optional: true, + }, + "widget-display": { + kind: "parsed", + parse: String, + brief: "Inline widget display type (line, bar, table, big_number, ...)", + optional: true, + }, + "widget-dataset": { + kind: "parsed", + parse: String, + brief: "Inline widget dataset (default: spans)", + optional: true, + }, + "widget-query": { + kind: "parsed", + parse: String, + brief: "Inline widget aggregate (e.g. count, p95:span.duration)", + variadic: true, + optional: true, + }, + "widget-where": { + kind: "parsed", + parse: String, + brief: "Inline widget search conditions filter", + optional: true, + }, + "widget-group-by": { + kind: "parsed", + parse: String, + brief: "Inline widget group-by column (repeatable)", + variadic: true, + optional: true, + }, + "widget-sort": { + kind: "parsed", + parse: String, + brief: "Inline widget order by (prefix - for desc)", + optional: true, + }, + "widget-limit": { + kind: "parsed", + parse: numberParser, + brief: "Inline widget result limit", + optional: true, + }, + }, + }, + async func(this: SentryContext, flags: CreateFlags, ...args: string[]) { + const { cwd } = this; + + const { title, targetArg } = parsePositionalArgs(args); + const parsed = parseOrgProjectArg(targetArg); + const { orgSlug, projectIds } = await resolveDashboardTarget(parsed, cwd); + + const widgets: DashboardWidget[] = []; + if (flags["widget-display"]) { + const validated = buildInlineWidget(flags); + widgets.push(assignDefaultLayout(validated, widgets)); + } else if (flags["widget-title"]) { + throw new ValidationError( + "Missing --widget-display. Both --widget-title and --widget-display are required for inline widgets.\n\n" + + "Example:\n" + + " sentry dashboard create 'My Dashboard' --widget-title \"Error Count\" --widget-display big_number --widget-query count\n\n" + + `Valid display types: ${DISPLAY_TYPES.join(", ")}`, + "widget-display" + ); + } + + const dashboard = await createDashboard(orgSlug, { + title, + widgets, + projects: projectIds.length > 0 ? projectIds : undefined, + }); + const url = buildDashboardUrl(orgSlug, dashboard.id); + + return { + data: { ...dashboard, url } as CreateResult, + }; + }, +}); diff --git a/src/commands/dashboard/index.ts b/src/commands/dashboard/index.ts new file mode 100644 index 000000000..adcf750fd --- /dev/null +++ b/src/commands/dashboard/index.ts @@ -0,0 +1,22 @@ +import { buildRouteMap } from "@stricli/core"; +import { createCommand } from "./create.js"; +import { listCommand } from "./list.js"; +import { viewCommand } from "./view.js"; + +export const dashboardRoute = buildRouteMap({ + routes: { + list: listCommand, + view: viewCommand, + create: createCommand, + }, + docs: { + brief: "Manage Sentry dashboards", + fullDescription: + "View and manage dashboards in your Sentry organization.\n\n" + + "Commands:\n" + + " list List dashboards\n" + + " view View a dashboard\n" + + " create Create a dashboard", + hideRoute: {}, + }, +}); diff --git a/src/commands/dashboard/list.ts b/src/commands/dashboard/list.ts new file mode 100644 index 000000000..5c3bd97e2 --- /dev/null +++ b/src/commands/dashboard/list.ts @@ -0,0 +1,148 @@ +/** + * sentry dashboard list + * + * List dashboards in a Sentry organization. + */ + +import type { SentryContext } from "../../context.js"; +import { listDashboards } from "../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { openInBrowser } from "../../lib/browser.js"; +import { buildCommand } from "../../lib/command.js"; +import { colorTag, escapeMarkdownCell } from "../../lib/formatters/markdown.js"; +import { type Column, writeTable } from "../../lib/formatters/table.js"; +import { + applyFreshFlag, + buildListLimitFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; +import { withProgress } from "../../lib/polling.js"; +import { + buildDashboardsListUrl, + buildDashboardUrl, +} from "../../lib/sentry-urls.js"; +import type { DashboardListItem } from "../../types/dashboard.js"; +import type { Writer } from "../../types/index.js"; +import { resolveOrgFromTarget } from "./resolve.js"; + +type ListFlags = { + readonly web: boolean; + readonly fresh: boolean; + readonly limit: number; + readonly json: boolean; + readonly fields?: string[]; +}; + +type DashboardListResult = { + dashboards: DashboardListItem[]; + orgSlug: string; +}; + +/** + * Format dashboard list for human-readable terminal output. + * + * Renders a table with ID, title (clickable link), and widget count columns. + * Returns "No dashboards found." for empty results. + */ +function formatDashboardListHuman(result: DashboardListResult): string { + if (result.dashboards.length === 0) { + return "No dashboards found."; + } + + type DashboardRow = { + id: string; + title: string; + widgets: string; + }; + + const rows: DashboardRow[] = result.dashboards.map((d) => { + const url = buildDashboardUrl(result.orgSlug, d.id); + return { + id: d.id, + title: `${escapeMarkdownCell(d.title)}\n${colorTag("muted", url)}`, + widgets: String(d.widgetDisplay?.length ?? 0), + }; + }); + + const columns: Column<DashboardRow>[] = [ + { header: "ID", value: (r) => r.id }, + { header: "TITLE", value: (r) => r.title }, + { header: "WIDGETS", value: (r) => r.widgets }, + ]; + + const parts: string[] = []; + const buffer: Writer = { write: (s) => parts.push(s) }; + writeTable(buffer, rows, columns); + + return parts.join("").trimEnd(); +} + +export const listCommand = buildCommand({ + docs: { + brief: "List dashboards", + fullDescription: + "List dashboards in a Sentry organization.\n\n" + + "Examples:\n" + + " sentry dashboard list\n" + + " sentry dashboard list my-org/\n" + + " sentry dashboard list --json\n" + + " sentry dashboard list --web", + }, + output: { + json: true, + human: formatDashboardListHuman, + jsonTransform: (result: DashboardListResult) => result.dashboards, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project", + brief: + "<org>/ (all projects), <org>/<project>, or <project> (search)", + parse: String, + optional: true, + }, + ], + }, + flags: { + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, + limit: buildListLimitFlag("dashboards"), + fresh: FRESH_FLAG, + }, + aliases: { ...FRESH_ALIASES, w: "web", n: "limit" }, + }, + async func(this: SentryContext, flags: ListFlags, target?: string) { + applyFreshFlag(flags); + const { cwd } = this; + + const parsed = parseOrgProjectArg(target); + const orgSlug = await resolveOrgFromTarget( + parsed, + cwd, + "sentry dashboard list <org>/" + ); + + if (flags.web) { + await openInBrowser(buildDashboardsListUrl(orgSlug), "dashboards"); + return; + } + + const dashboards = await withProgress( + { message: `Fetching dashboards (up to ${flags.limit})...` }, + () => listDashboards(orgSlug, { perPage: flags.limit }) + ); + const url = buildDashboardsListUrl(orgSlug); + + return { + data: { dashboards, orgSlug } as DashboardListResult, + hint: dashboards.length > 0 ? `Dashboards: ${url}` : undefined, + }; + }, +}); diff --git a/src/commands/dashboard/resolve.ts b/src/commands/dashboard/resolve.ts new file mode 100644 index 000000000..81f13c574 --- /dev/null +++ b/src/commands/dashboard/resolve.ts @@ -0,0 +1,121 @@ +/** + * Shared dashboard resolution utilities + * + * Provides org resolution from parsed target arguments and dashboard + * ID resolution from numeric IDs or title strings. + */ + +import { listDashboards } from "../../lib/api-client.js"; +import type { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { ContextError, ValidationError } from "../../lib/errors.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { isAllDigits } from "../../lib/utils.js"; + +/** + * Resolve org slug from a parsed org/project target argument. + * + * Dashboard commands only need the org (dashboards are org-scoped), so + * explicit, org-all, project-search, and auto-detect all resolve to just + * the org slug. + * + * @param parsed - Parsed org/project argument + * @param cwd - Current working directory for auto-detection + * @param usageHint - Usage example for error messages + * @returns Organization slug + */ +export async function resolveOrgFromTarget( + parsed: ReturnType<typeof parseOrgProjectArg>, + cwd: string, + usageHint: string +): Promise<string> { + switch (parsed.type) { + case "explicit": + case "org-all": + return parsed.org; + case "project-search": + case "auto-detect": { + const resolved = await resolveOrg({ cwd }); + if (!resolved) { + throw new ContextError("Organization", usageHint); + } + return resolved.org; + } + default: { + const _exhaustive: never = parsed; + throw new Error( + `Unexpected parsed type: ${(_exhaustive as { type: string }).type}` + ); + } + } +} + +/** + * Parse a dashboard reference and optional target from array positional args. + * + * Handles: + * - `<id-or-title>` — single arg (auto-detect org) + * - `<target> <id-or-title>` — explicit target + dashboard ref + * + * @param args - Raw positional arguments + * @param usageHint - Error message label (e.g. "Dashboard ID or title") + * @returns Dashboard reference string and optional target arg + */ +export function parseDashboardPositionalArgs(args: string[]): { + dashboardRef: string; + targetArg: string | undefined; +} { + if (args.length === 0) { + throw new ValidationError( + "Dashboard ID or title is required.", + "dashboard" + ); + } + if (args.length === 1) { + return { + dashboardRef: args[0] as string, + targetArg: undefined, + }; + } + return { + dashboardRef: args[1] as string, + targetArg: args[0] as string, + }; +} + +/** + * Resolve a dashboard reference (numeric ID or title) to a numeric ID string. + * + * If the reference is all digits, returns it directly. Otherwise, lists + * dashboards in the org and finds a case-insensitive title match. + * + * @param orgSlug - Organization slug + * @param ref - Dashboard reference (numeric ID or title) + * @returns Numeric dashboard ID as a string + */ +export async function resolveDashboardId( + orgSlug: string, + ref: string +): Promise<string> { + if (isAllDigits(ref)) { + return ref; + } + + const dashboards = await listDashboards(orgSlug); + const lowerRef = ref.toLowerCase(); + const match = dashboards.find((d) => d.title.toLowerCase() === lowerRef); + + if (!match) { + const available = dashboards + .slice(0, 5) + .map((d) => ` ${d.id} ${d.title}`) + .join("\n"); + const suffix = + dashboards.length > 5 ? `\n ... and ${dashboards.length - 5} more` : ""; + throw new ValidationError( + `No dashboard with title '${ref}' found in '${orgSlug}'.\n\n` + + `Available dashboards:\n${available}${suffix}` + ); + } + + return match.id; +} diff --git a/src/commands/dashboard/view.ts b/src/commands/dashboard/view.ts new file mode 100644 index 000000000..3f903710c --- /dev/null +++ b/src/commands/dashboard/view.ts @@ -0,0 +1,96 @@ +/** + * sentry dashboard view + * + * View details of a specific dashboard. + */ + +import type { SentryContext } from "../../context.js"; +import { getDashboard } from "../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { openInBrowser } from "../../lib/browser.js"; +import { buildCommand } from "../../lib/command.js"; +import { formatDashboardView } from "../../lib/formatters/human.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; +import { buildDashboardUrl } from "../../lib/sentry-urls.js"; +import type { DashboardDetail } from "../../types/dashboard.js"; +import { + parseDashboardPositionalArgs, + resolveDashboardId, + resolveOrgFromTarget, +} from "./resolve.js"; + +type ViewFlags = { + readonly web: boolean; + readonly fresh: boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +type ViewResult = DashboardDetail & { url: string }; + +export const viewCommand = buildCommand({ + docs: { + brief: "View a dashboard", + fullDescription: + "View details of a specific Sentry dashboard.\n\n" + + "The dashboard can be specified by numeric ID or title.\n\n" + + "Examples:\n" + + " sentry dashboard view 12345\n" + + " sentry dashboard view 'My Dashboard'\n" + + " sentry dashboard view my-org/ 12345\n" + + " sentry dashboard view 12345 --json\n" + + " sentry dashboard view 12345 --web", + }, + output: { + json: true, + human: formatDashboardView, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "[<org/project>] <dashboard-id-or-title>", + parse: String, + }, + }, + flags: { + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, + fresh: FRESH_FLAG, + }, + aliases: { ...FRESH_ALIASES, w: "web" }, + }, + async func(this: SentryContext, flags: ViewFlags, ...args: string[]) { + applyFreshFlag(flags); + const { cwd } = this; + + const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args); + const parsed = parseOrgProjectArg(targetArg); + const orgSlug = await resolveOrgFromTarget( + parsed, + cwd, + "sentry dashboard view <org>/ <id>" + ); + const dashboardId = await resolveDashboardId(orgSlug, dashboardRef); + + const url = buildDashboardUrl(orgSlug, dashboardId); + + if (flags.web) { + await openInBrowser(url, "dashboard"); + return; + } + + const dashboard = await getDashboard(orgSlug, dashboardId); + + return { + data: { ...dashboard, url } as ViewResult, + }; + }, +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 41c992ec2..5866f2c15 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -19,6 +19,11 @@ * - users: current user info */ +export { + createDashboard, + getDashboard, + listDashboards, +} from "./api/dashboards.js"; export { findEventAcrossOrgs, getEvent, @@ -91,10 +96,8 @@ export { getDetailedTrace, listTransactions, } from "./api/traces.js"; - export { getProductTrials, startProductTrial, } from "./api/trials.js"; - export { getCurrentUser } from "./api/users.js"; diff --git a/src/lib/api/dashboards.ts b/src/lib/api/dashboards.ts new file mode 100644 index 000000000..3c88b228f --- /dev/null +++ b/src/lib/api/dashboards.ts @@ -0,0 +1,74 @@ +/** + * Dashboard API functions + * + * CRUD operations for Sentry dashboards. + */ + +import type { + DashboardDetail, + DashboardListItem, + DashboardWidget, +} from "../../types/dashboard.js"; + +import { resolveOrgRegion } from "../region.js"; + +import { apiRequestToRegion } from "./infrastructure.js"; + +/** + * List dashboards in an organization. + * + * @param orgSlug - Organization slug + * @param options - Optional pagination parameters + * @returns Array of dashboard list items + */ +export async function listDashboards( + orgSlug: string, + options: { perPage?: number } = {} +): Promise<DashboardListItem[]> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion<DashboardListItem[]>( + regionUrl, + `/organizations/${orgSlug}/dashboards/`, + { params: { per_page: options.perPage } } + ); + return data; +} + +/** + * Get a dashboard by ID. + * + * @param orgSlug - Organization slug + * @param dashboardId - Dashboard ID + * @returns Full dashboard detail with widgets + */ +export async function getDashboard( + orgSlug: string, + dashboardId: string +): Promise<DashboardDetail> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion<DashboardDetail>( + regionUrl, + `/organizations/${orgSlug}/dashboards/${dashboardId}/` + ); + return data; +} + +/** + * Create a new dashboard. + * + * @param orgSlug - Organization slug + * @param body - Dashboard creation body (title, optional widgets) + * @returns Created dashboard detail + */ +export async function createDashboard( + orgSlug: string, + body: { title: string; widgets?: DashboardWidget[]; projects?: number[] } +): Promise<DashboardDetail> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion<DashboardDetail>( + regionUrl, + `/organizations/${orgSlug}/dashboards/`, + { method: "POST", body } + ); + return data; +} diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 765bd6e86..33602679e 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -2122,3 +2122,84 @@ export function formatUpgradeResult(data: UpgradeResult): string { return renderMarkdown(lines.join("\n")); } + +// Dashboard formatters + +/** + * Format a created dashboard for human-readable output. + */ +export function formatDashboardCreated(result: { + id: string; + title: string; + url: string; +}): string { + const lines: string[] = [ + `Created dashboard '${escapeMarkdownInline(result.title)}' (ID: ${result.id})`, + "", + `URL: ${result.url}`, + ]; + return renderMarkdown(lines.join("\n")); +} + +/** + * Format a dashboard view for human-readable output. + */ +export function formatDashboardView(result: { + id: string; + title: string; + widgets?: Array<{ + title: string; + displayType: string; + widgetType?: string; + layout?: { x: number; y: number; w: number; h: number }; + }>; + dateCreated?: string; + url: string; +}): string { + const lines: string[] = []; + + const kvRows: [string, string][] = [ + ["Title", escapeMarkdownInline(result.title)], + ["ID", result.id], + ]; + if (result.dateCreated) { + kvRows.push(["Created", result.dateCreated]); + } + kvRows.push(["URL", result.url]); + + lines.push(mdKvTable(kvRows)); + + const widgets = result.widgets ?? []; + if (widgets.length > 0) { + lines.push(""); + lines.push(`**Widgets** (${widgets.length}):`); + lines.push(""); + + type WidgetRow = { + title: string; + displayType: string; + widgetType: string; + layout: string; + }; + const widgetRows: WidgetRow[] = widgets.map((w) => ({ + title: escapeMarkdownCell(w.title), + displayType: w.displayType, + widgetType: w.widgetType ?? "", + layout: w.layout + ? `(${w.layout.x},${w.layout.y}) ${w.layout.w}×${w.layout.h}` + : "", + })); + + lines.push(mdTableHeader(["TITLE", "DISPLAY", "TYPE", "LAYOUT"])); + for (const row of widgetRows) { + lines.push( + mdRow([row.title, row.displayType, row.widgetType, row.layout]) + ); + } + } else { + lines.push(""); + lines.push("No widgets."); + } + + return renderMarkdown(lines.join("\n")); +} diff --git a/src/lib/sentry-urls.ts b/src/lib/sentry-urls.ts index b2aac728a..49b764bb1 100644 --- a/src/lib/sentry-urls.ts +++ b/src/lib/sentry-urls.ts @@ -162,6 +162,38 @@ export function buildLogsUrl(orgSlug: string, logId?: string): string { return logId ? `${base}?query=sentry.item_id:${logId}` : base; } +// Dashboard URLs + +/** + * Build URL to the dashboards list page. + * + * @param orgSlug - Organization slug + * @returns Full URL to the dashboards list page + */ +export function buildDashboardsListUrl(orgSlug: string): string { + if (isSaaS()) { + return `${getOrgBaseUrl(orgSlug)}/dashboards/`; + } + return `${getSentryBaseUrl()}/organizations/${orgSlug}/dashboards/`; +} + +/** + * Build URL to view a specific dashboard. + * + * @param orgSlug - Organization slug + * @param dashboardId - Dashboard ID + * @returns Full URL to the dashboard view page + */ +export function buildDashboardUrl( + orgSlug: string, + dashboardId: string +): string { + if (isSaaS()) { + return `${getOrgBaseUrl(orgSlug)}/dashboard/${dashboardId}/`; + } + return `${getSentryBaseUrl()}/organizations/${orgSlug}/dashboard/${dashboardId}/`; +} + /** * Build URL to view a trace in Sentry. * diff --git a/src/types/dashboard.ts b/src/types/dashboard.ts new file mode 100644 index 000000000..b19af4123 --- /dev/null +++ b/src/types/dashboard.ts @@ -0,0 +1,506 @@ +/** + * Dashboard types and schemas + * + * Zod schemas and TypeScript types for Sentry Dashboard API responses. + * Includes utility functions for stripping server-generated fields + * before PUT requests, and strict input validation for user-authored widgets. + */ + +import { z } from "zod"; + +import { ValidationError } from "../lib/errors.js"; + +// --------------------------------------------------------------------------- +// Widget type and display type enums +// +// Source: sentry/src/sentry/models/dashboard_widget.py +// Also in: @sentry/api types (cli/node_modules/@sentry/api/dist/types.gen.d.ts) +// --------------------------------------------------------------------------- + +/** + * Valid widget types (dataset selectors). + * + * Source: sentry/src/sentry/models/dashboard_widget.py DashboardWidgetTypes.TYPES + */ +export const WIDGET_TYPES = [ + "discover", + "issue", + "metrics", + "error-events", + "transaction-like", + "spans", + "logs", + "tracemetrics", + "preprod-app-size", +] as const; + +export type WidgetType = (typeof WIDGET_TYPES)[number]; + +/** Default widgetType — the modern spans dataset covers most use cases */ +export const DEFAULT_WIDGET_TYPE: WidgetType = "spans"; + +/** + * Valid widget display types (visualization formats). + * + * Source: sentry/src/sentry/models/dashboard_widget.py DashboardWidgetDisplayTypes.TYPES + */ +export const DISPLAY_TYPES = [ + "line", + "area", + "stacked_area", + "bar", + "table", + "big_number", + "top_n", + "details", + "categorical_bar", + "wheel", + "rage_and_dead_clicks", + "server_tree", + "text", + "agents_traces_table", +] as const; + +export type DisplayType = (typeof DISPLAY_TYPES)[number]; + +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + +/** Schema for a single query within a dashboard widget */ +export const DashboardWidgetQuerySchema = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + conditions: z.string().optional(), + columns: z.array(z.string()).optional(), + aggregates: z.array(z.string()).optional(), + fieldAliases: z.array(z.string()).optional(), + orderby: z.string().optional(), + fields: z.array(z.string()).optional(), + widgetId: z.string().optional(), + dateCreated: z.string().optional(), + }) + .passthrough(); + +/** Schema for widget layout position */ +export const DashboardWidgetLayoutSchema = z + .object({ + x: z.number(), + y: z.number(), + w: z.number(), + h: z.number(), + minH: z.number().optional(), + isResizable: z.boolean().optional(), + }) + .passthrough(); + +/** Schema for a single dashboard widget */ +export const DashboardWidgetSchema = z + .object({ + id: z.string().optional(), + title: z.string(), + displayType: z.string(), + widgetType: z.string().optional(), + interval: z.string().optional(), + queries: z.array(DashboardWidgetQuerySchema).optional(), + layout: DashboardWidgetLayoutSchema.optional(), + thresholds: z.unknown().optional(), + limit: z.number().nullable().optional(), + dashboardId: z.string().optional(), + dateCreated: z.string().optional(), + }) + .passthrough(); + +/** Schema for dashboard list items (lightweight, from GET /dashboards/) */ +export const DashboardListItemSchema = z + .object({ + id: z.string(), + title: z.string(), + dateCreated: z.string().optional(), + createdBy: z + .object({ + name: z.string().optional(), + email: z.string().optional(), + }) + .optional(), + widgetDisplay: z.array(z.string()).optional(), + }) + .passthrough(); + +/** Schema for full dashboard detail (from GET /dashboards/{id}/) */ +export const DashboardDetailSchema = z + .object({ + id: z.string(), + title: z.string(), + widgets: z.array(DashboardWidgetSchema).optional(), + dateCreated: z.string().optional(), + createdBy: z + .object({ + name: z.string().optional(), + email: z.string().optional(), + }) + .optional(), + projects: z.array(z.number()).optional(), + environment: z.array(z.string()).optional(), + period: z.string().nullable().optional(), + }) + .passthrough(); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type DashboardWidgetQuery = z.infer<typeof DashboardWidgetQuerySchema>; +export type DashboardWidgetLayout = z.infer<typeof DashboardWidgetLayoutSchema>; +export type DashboardWidget = z.infer<typeof DashboardWidgetSchema>; +export type DashboardListItem = z.infer<typeof DashboardListItemSchema>; +export type DashboardDetail = z.infer<typeof DashboardDetailSchema>; + +// --------------------------------------------------------------------------- +// Strict input schema for user-authored widgets +// --------------------------------------------------------------------------- + +/** + * Strict schema for user-authored widget JSON (create/add/edit input). + * Validates displayType and widgetType against known Sentry enums. + * Defaults widgetType to "spans" when not provided. + * + * Use DashboardWidgetSchema (permissive) for parsing server responses. + */ +export const DashboardWidgetInputSchema = z + .object({ + title: z.string(), + displayType: z.enum(DISPLAY_TYPES), + widgetType: z.enum(WIDGET_TYPES).default(DEFAULT_WIDGET_TYPE), + interval: z.string().optional(), + queries: z.array(DashboardWidgetQuerySchema).optional(), + layout: DashboardWidgetLayoutSchema.optional(), + thresholds: z.unknown().optional(), + limit: z.number().nullable().optional(), + }) + .passthrough(); + +/** + * Parse and validate user-authored widget JSON with strict enum checks. + * Throws ValidationError with actionable messages listing valid values. + * + * @param raw - Raw parsed JSON from user's widget file + * @returns Validated widget with widgetType defaulted to "spans" if omitted + */ +export function parseWidgetInput(raw: unknown): DashboardWidget { + const result = DashboardWidgetInputSchema.safeParse(raw); + if (result.success) { + return result.data; + } + + const issues = result.error.issues.map((issue) => { + if (issue.path.includes("displayType")) { + return `Invalid displayType. Valid values: ${DISPLAY_TYPES.join(", ")}`; + } + if (issue.path.includes("widgetType")) { + return `Invalid widgetType. Valid values: ${WIDGET_TYPES.join(", ")}`; + } + return `${issue.path.join(".")}: ${issue.message}`; + }); + throw new ValidationError( + `Invalid widget definition:\n${issues.join("\n")}`, + "widget-json" + ); +} + +// --------------------------------------------------------------------------- +// Aggregate functions & search filter enums +// --------------------------------------------------------------------------- + +/** + * Public aggregate functions available in the spans dataset (default for dashboard widgets). + * These are the function names users pass to --query (e.g. `--query count`, `--query p95:span.duration`). + * + * Source: getsentry/sentry spans_indexed.py SpansIndexedDatasetConfig.function_converter + * https://github.com/getsentry/sentry/blob/master/src/sentry/search/events/datasets/spans_indexed.py#L89-L363 + * + * Aliases (sps→eps, spm→epm) defined in constants.py SPAN_FUNCTION_ALIASES: + * https://github.com/getsentry/sentry/blob/master/src/sentry/search/events/constants.py#L334 + */ +export const SPAN_AGGREGATE_FUNCTIONS = [ + "count", + "count_unique", + "sum", + "avg", + "percentile", + "p50", + "p75", + "p90", + "p95", + "p99", + "p100", + "eps", + "epm", + "sps", + "spm", + "any", + "min", + "max", +] as const; + +export type SpanAggregateFunction = (typeof SPAN_AGGREGATE_FUNCTIONS)[number]; + +/** + * Additional aggregate functions from the discover dataset. + * Available when widgetType is "discover" or "error-events". + * + * Source: getsentry/sentry discover.py DiscoverDatasetConfig.function_converter + * https://github.com/getsentry/sentry/blob/master/src/sentry/search/events/datasets/discover.py#L188-L1095 + * + * Aliases (tpm→epm, tps→eps) defined in constants.py FUNCTION_ALIASES: + * https://github.com/getsentry/sentry/blob/master/src/sentry/search/events/constants.py#L325-L328 + */ +export const DISCOVER_AGGREGATE_FUNCTIONS = [ + ...SPAN_AGGREGATE_FUNCTIONS, + "failure_count", + "failure_rate", + "apdex", + "count_miserable", + "user_misery", + "count_web_vitals", + "count_if", + "count_at_least", + "last_seen", + "latest_event", + "var", + "stddev", + "cov", + "corr", + "performance_score", + "opportunity_score", + "count_scores", + "tpm", + "tps", +] as const; + +export type DiscoverAggregateFunction = + (typeof DISCOVER_AGGREGATE_FUNCTIONS)[number]; + +/** Zod schema for validating a span aggregate function name */ +export const SpanAggregateFunctionSchema = z.enum(SPAN_AGGREGATE_FUNCTIONS); + +/** Zod schema for validating a discover aggregate function name */ +export const DiscoverAggregateFunctionSchema = z.enum( + DISCOVER_AGGREGATE_FUNCTIONS +); + +/** + * Valid `is:` filter values for issue search conditions (--where flag). + * Only valid when widgetType is "issue". Other datasets don't support `is:`. + * + * Status values from GroupStatus: + * https://github.com/getsentry/sentry/blob/master/src/sentry/models/group.py#L196-L204 + * + * Substatus values from SUBSTATUS_UPDATE_CHOICES: + * https://github.com/getsentry/sentry/blob/master/src/sentry/types/group.py#L33-L41 + * + * Assignment/link filters from is_filter_translation: + * https://github.com/getsentry/sentry/blob/master/src/sentry/issues/issue_search.py#L45-L51 + */ +export const IS_FILTER_VALUES = [ + // Status (GroupStatus) + "resolved", + "unresolved", + "ignored", + "archived", + "muted", + "reprocessing", + // Substatus (GroupSubStatus) + "escalating", + "ongoing", + "regressed", + "new", + "archived_until_escalating", + "archived_until_condition_met", + "archived_forever", + // Assignment & linking + "assigned", + "unassigned", + "for_review", + "linked", + "unlinked", +] as const; + +export type IsFilterValue = (typeof IS_FILTER_VALUES)[number]; + +/** Zod schema for validating an `is:` filter value */ +export const IsFilterValueSchema = z.enum(IS_FILTER_VALUES); + +// --------------------------------------------------------------------------- +// Aggregate & sort parsing (quote-free CLI shorthand) +// --------------------------------------------------------------------------- + +/** + * Parse a shorthand aggregate expression into Sentry query syntax. + * Accepts three formats: + * "count" → "count()" + * "p95:span.duration" → "p95(span.duration)" + * "count()" → "count()" (passthrough if already has parens) + */ +export function parseAggregate(input: string): string { + if (input.includes("(")) { + return input; + } + const colonIdx = input.indexOf(":"); + if (colonIdx > 0) { + return `${input.slice(0, colonIdx)}(${input.slice(colonIdx + 1)})`; + } + return `${input}()`; +} + +/** + * Parse a sort expression with optional `-` prefix for descending. + * Uses the same shorthand as {@link parseAggregate}. + * "-count" → "-count()" + * "p95:span.duration" → "p95(span.duration)" + * "-p95:span.duration" → "-p95(span.duration)" + */ +export function parseSortExpression(input: string): string { + if (input.startsWith("-")) { + return `-${parseAggregate(input.slice(1))}`; + } + return parseAggregate(input); +} + +// --------------------------------------------------------------------------- +// Query preparation for Sentry API +// --------------------------------------------------------------------------- + +/** Maximum result limits by display type */ +const MAX_LIMITS: Partial<Record<string, number>> = { + table: 10, + bar: 10, +}; + +/** + * Prepare widget queries for the Sentry API. + * Auto-computes `fields` from columns + aggregates. + * Defaults `conditions` to "" when missing. + * Enforces per-display-type limit maximums. + */ +export function prepareWidgetQueries(widget: DashboardWidget): DashboardWidget { + // Enforce limit maximums + const maxLimit = MAX_LIMITS[widget.displayType]; + if ( + maxLimit !== undefined && + widget.limit !== undefined && + widget.limit !== null && + widget.limit > maxLimit + ) { + throw new ValidationError( + `The maximum limit for ${widget.displayType} widgets is ${maxLimit}. Got: ${widget.limit}.`, + "limit" + ); + } + + if (!widget.queries) { + return widget; + } + return { + ...widget, + queries: widget.queries.map((q) => ({ + ...q, + conditions: q.conditions ?? "", + fields: q.fields ?? [...(q.columns ?? []), ...(q.aggregates ?? [])], + })), + }; +} + +// --------------------------------------------------------------------------- +// Auto-layout utilities +// --------------------------------------------------------------------------- + +/** Sentry dashboard grid column count */ +const GRID_COLUMNS = 6; + +/** Default widget dimensions by displayType */ +const DEFAULT_WIDGET_SIZE: Partial< + Record<DisplayType, { w: number; h: number; minH: number }> +> = { + big_number: { w: 2, h: 1, minH: 1 }, + line: { w: 3, h: 2, minH: 2 }, + area: { w: 3, h: 2, minH: 2 }, + bar: { w: 3, h: 2, minH: 2 }, + table: { w: 6, h: 2, minH: 2 }, +}; +const FALLBACK_SIZE = { w: 3, h: 2, minH: 2 }; + +/** Build a set of occupied grid cells and the max bottom edge from existing layouts. */ +function buildOccupiedGrid(widgets: DashboardWidget[]): { + occupied: Set<string>; + maxY: number; +} { + const occupied = new Set<string>(); + let maxY = 0; + for (const w of widgets) { + if (!w.layout) { + continue; + } + const bottom = w.layout.y + w.layout.h; + if (bottom > maxY) { + maxY = bottom; + } + for (let y = w.layout.y; y < bottom; y++) { + for (let x = w.layout.x; x < w.layout.x + w.layout.w; x++) { + occupied.add(`${x},${y}`); + } + } + } + return { occupied, maxY }; +} + +/** Check whether a rectangle fits at a position without overlapping occupied cells. */ +function regionFits( + occupied: Set<string>, + rect: { px: number; py: number; w: number; h: number } +): boolean { + for (let dy = 0; dy < rect.h; dy++) { + for (let dx = 0; dx < rect.w; dx++) { + if (occupied.has(`${rect.px + dx},${rect.py + dy}`)) { + return false; + } + } + } + return true; +} + +/** + * Assign a default layout to a widget if it doesn't already have one. + * Packs the widget into the first available space in a 6-column grid, + * scanning rows top-to-bottom and left-to-right. + * + * @param widget - Widget that may be missing a layout + * @param existingWidgets - Widgets already in the dashboard (used to compute placement) + * @returns Widget with layout guaranteed + */ +export function assignDefaultLayout( + widget: DashboardWidget, + existingWidgets: DashboardWidget[] +): DashboardWidget { + if (widget.layout) { + return widget; + } + + const { w, h, minH } = + DEFAULT_WIDGET_SIZE[widget.displayType as DisplayType] ?? FALLBACK_SIZE; + + const { occupied, maxY } = buildOccupiedGrid(existingWidgets); + + // Scan rows to find the first position where the widget fits + for (let y = 0; y <= maxY; y++) { + for (let x = 0; x <= GRID_COLUMNS - w; x++) { + if (regionFits(occupied, { px: x, py: y, w, h })) { + return { ...widget, layout: { x, y, w, h, minH } }; + } + } + } + + // No gap found — place below everything + return { ...widget, layout: { x: 0, y: maxY, w, h, minH } }; +} diff --git a/src/types/index.ts b/src/types/index.ts index 6db7ecf8d..42a96ab29 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,6 +19,21 @@ export { ProjectAliasesSchema, SentryConfigSchema, } from "./config.js"; +// Dashboard types +export type { + DashboardDetail, + DashboardListItem, + DashboardWidget, + DashboardWidgetLayout, + DashboardWidgetQuery, +} from "./dashboard.js"; +export { + DashboardDetailSchema, + DashboardListItemSchema, + DashboardWidgetLayoutSchema, + DashboardWidgetQuerySchema, + DashboardWidgetSchema, +} from "./dashboard.js"; // OAuth types and schemas export type { DeviceCodeResponse, @@ -83,7 +98,6 @@ export type { TransactionsResponse, UserRegionsResponse, } from "./sentry.js"; - export { CustomerTrialInfoSchema, DetailedLogsResponseSchema, diff --git a/test/commands/dashboard/create.test.ts b/test/commands/dashboard/create.test.ts new file mode 100644 index 000000000..fb78cc173 --- /dev/null +++ b/test/commands/dashboard/create.test.ts @@ -0,0 +1,234 @@ +/** + * Dashboard Create Command Tests + * + * Tests for the dashboard create command in src/commands/dashboard/create.ts. + * Uses spyOn pattern to mock API client and resolve-target. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; + +import { createCommand } from "../../../src/commands/dashboard/create.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +import { ContextError, ValidationError } from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import type { DashboardDetail } from "../../../src/types/dashboard.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockContext(cwd = "/tmp") { + const stdoutWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: mock(() => true) }, + cwd, + setContext: mock(() => { + // no-op for test + }), + }, + stdoutWrite, + }; +} + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +const sampleDashboard: DashboardDetail = { + id: "123", + title: "My Dashboard", + widgets: [], + dateCreated: "2026-03-01T10:00:00Z", +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("dashboard create", () => { + let createDashboardSpy: ReturnType<typeof spyOn>; + let resolveOrgSpy: ReturnType<typeof spyOn>; + let resolveAllTargetsSpy: ReturnType<typeof spyOn>; + let fetchProjectIdSpy: ReturnType<typeof spyOn>; + + beforeEach(() => { + createDashboardSpy = spyOn(apiClient, "createDashboard"); + resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + resolveAllTargetsSpy = spyOn(resolveTarget, "resolveAllTargets"); + fetchProjectIdSpy = spyOn(resolveTarget, "fetchProjectId"); + + // Default mocks + resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); + resolveAllTargetsSpy.mockResolvedValue({ targets: [] }); + createDashboardSpy.mockResolvedValue(sampleDashboard); + fetchProjectIdSpy.mockResolvedValue(999); + }); + + afterEach(() => { + createDashboardSpy.mockRestore(); + resolveOrgSpy.mockRestore(); + resolveAllTargetsSpy.mockRestore(); + fetchProjectIdSpy.mockRestore(); + }); + + test("creates dashboard with title and verifies API args", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "My Dashboard"); + + expect(createDashboardSpy).toHaveBeenCalledWith("acme-corp", { + title: "My Dashboard", + widgets: [], + projects: undefined, + }); + }); + + test("JSON output contains dashboard data and url", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: true }, "My Dashboard"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.id).toBe("123"); + expect(parsed.title).toBe("My Dashboard"); + expect(parsed.url).toContain("dashboard/123"); + }); + + test("human output contains 'Created dashboard' and title", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "My Dashboard"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Created dashboard"); + expect(output).toContain("My Dashboard"); + }); + + test("throws ValidationError when title is missing", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call(context, { json: false }) + .catch((e: Error) => e); + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toContain("Dashboard title is required"); + }); + + test("two args parses target + title correctly", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "my-org/", "My Dashboard"); + + expect(createDashboardSpy).toHaveBeenCalledWith("my-org", { + title: "My Dashboard", + widgets: [], + projects: undefined, + }); + }); + + test("--widget-display and --widget-title creates dashboard with widget", async () => { + createDashboardSpy.mockResolvedValue({ + ...sampleDashboard, + widgets: [ + { + title: "Error Count", + displayType: "big_number", + widgetType: "spans", + }, + ], + }); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call( + context, + { + json: false, + "widget-title": "Error Count", + "widget-display": "big_number", + }, + "My Dashboard" + ); + + expect(createDashboardSpy).toHaveBeenCalledWith( + "acme-corp", + expect.objectContaining({ + title: "My Dashboard", + widgets: expect.arrayContaining([ + expect.objectContaining({ + title: "Error Count", + displayType: "big_number", + }), + ]), + }) + ); + }); + + test("--widget-title without --widget-display throws ValidationError", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call( + context, + { json: false, "widget-title": "Error Count" }, + "My Dashboard" + ) + .catch((e: Error) => e); + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toContain("--widget-display"); + }); + + test("--widget-display without --widget-title throws ValidationError", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call( + context, + { json: false, "widget-display": "big_number" }, + "My Dashboard" + ) + .catch((e: Error) => e); + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toContain("--widget-title"); + }); + + test("throws ContextError when org cannot be resolved", async () => { + resolveOrgSpy.mockResolvedValue(null); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + + await expect( + func.call(context, { json: false }, "My Dashboard") + ).rejects.toThrow(ContextError); + }); + + test("explicit org/project target calls fetchProjectId", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call( + context, + { json: false }, + "my-org/my-project", + "My Dashboard" + ); + + expect(fetchProjectIdSpy).toHaveBeenCalledWith("my-org", "my-project"); + }); +}); diff --git a/test/commands/dashboard/list.test.ts b/test/commands/dashboard/list.test.ts new file mode 100644 index 000000000..c74d1bb12 --- /dev/null +++ b/test/commands/dashboard/list.test.ts @@ -0,0 +1,252 @@ +/** + * Dashboard List Command Tests + * + * Tests for the dashboard list command in src/commands/dashboard/list.ts. + * Uses spyOn pattern to mock API client, resolve-target, and browser. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; + +import { listCommand } from "../../../src/commands/dashboard/list.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as browser from "../../../src/lib/browser.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as polling from "../../../src/lib/polling.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import type { DashboardListItem } from "../../../src/types/dashboard.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockContext(cwd = "/tmp") { + const stdoutWrite = mock(() => true); + const stderrWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: stderrWrite }, + cwd, + setContext: mock(() => { + // no-op for test + }), + }, + stdoutWrite, + stderrWrite, + }; +} + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +const DASHBOARD_A: DashboardListItem = { + id: "1", + title: "Errors Overview", + widgetDisplay: ["big_number", "line"], + dateCreated: "2026-01-15T10:00:00Z", +}; + +const DASHBOARD_B: DashboardListItem = { + id: "42", + title: "Performance", + widgetDisplay: ["table"], + dateCreated: "2026-02-20T12:00:00Z", +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("dashboard list command", () => { + let listDashboardsSpy: ReturnType<typeof spyOn>; + let resolveOrgSpy: ReturnType<typeof spyOn>; + let openInBrowserSpy: ReturnType<typeof spyOn>; + let withProgressSpy: ReturnType<typeof spyOn>; + + beforeEach(() => { + listDashboardsSpy = spyOn(apiClient, "listDashboards"); + resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + openInBrowserSpy = spyOn(browser, "openInBrowser").mockResolvedValue( + undefined as never + ); + // Bypass spinner — just run the callback directly + withProgressSpy = spyOn(polling, "withProgress").mockImplementation( + (_opts, fn) => + fn(() => { + /* no-op setMessage */ + }) + ); + }); + + afterEach(() => { + listDashboardsSpy.mockRestore(); + resolveOrgSpy.mockRestore(); + openInBrowserSpy.mockRestore(); + withProgressSpy.mockRestore(); + }); + + test("outputs JSON array of dashboards with --json", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsSpy.mockResolvedValue([DASHBOARD_A, DASHBOARD_B]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { json: true, web: false, fresh: false, limit: 30 }, + undefined + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(2); + expect(parsed[0].id).toBe("1"); + expect(parsed[0].title).toBe("Errors Overview"); + expect(parsed[1].id).toBe("42"); + }); + + test("outputs empty JSON array when no dashboards exist", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsSpy.mockResolvedValue([]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { json: true, web: false, fresh: false, limit: 30 }, + undefined + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(JSON.parse(output)).toEqual([]); + }); + + test("outputs human-readable table with column headers", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsSpy.mockResolvedValue([DASHBOARD_A, DASHBOARD_B]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { json: false, web: false, fresh: false, limit: 30 }, + undefined + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("ID"); + expect(output).toContain("TITLE"); + expect(output).toContain("WIDGETS"); + expect(output).toContain("Errors Overview"); + expect(output).toContain("Performance"); + }); + + test("shows empty state message when no dashboards exist", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsSpy.mockResolvedValue([]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { json: false, web: false, fresh: false, limit: 30 }, + undefined + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No dashboards found."); + }); + + test("human output footer contains dashboards URL", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsSpy.mockResolvedValue([DASHBOARD_A]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { json: false, web: false, fresh: false, limit: 30 }, + undefined + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("dashboards"); + expect(output).toContain("test-org"); + }); + + test("uses org from positional argument", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + listDashboardsSpy.mockResolvedValue([DASHBOARD_A]); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { json: true, web: false, fresh: false, limit: 30 }, + "my-org/" + ); + + expect(listDashboardsSpy).toHaveBeenCalledWith("my-org", { perPage: 30 }); + }); + + test("throws ContextError when org cannot be resolved", async () => { + resolveOrgSpy.mockResolvedValue(null); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + + await expect( + func.call( + context, + { json: false, web: false, fresh: false, limit: 30 }, + undefined + ) + ).rejects.toThrow("Organization"); + }); + + test("--web flag opens browser instead of listing", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { json: false, web: true, fresh: false, limit: 30 }, + undefined + ); + + expect(openInBrowserSpy).toHaveBeenCalled(); + expect(listDashboardsSpy).not.toHaveBeenCalled(); + }); + + test("passes limit to API via withProgress", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsSpy.mockResolvedValue([DASHBOARD_A]); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { json: true, web: false, fresh: false, limit: 10 }, + undefined + ); + + expect(withProgressSpy).toHaveBeenCalled(); + expect(listDashboardsSpy).toHaveBeenCalledWith("test-org", { + perPage: 10, + }); + }); +}); diff --git a/test/commands/dashboard/resolve.test.ts b/test/commands/dashboard/resolve.test.ts new file mode 100644 index 000000000..c67ef2342 --- /dev/null +++ b/test/commands/dashboard/resolve.test.ts @@ -0,0 +1,148 @@ +/** + * Dashboard Resolution Utility Tests + * + * Tests for positional argument parsing, dashboard ID resolution, + * and org resolution in src/commands/dashboard/resolve.ts. + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { + parseDashboardPositionalArgs, + resolveDashboardId, + resolveOrgFromTarget, +} from "../../../src/commands/dashboard/resolve.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +import { parseOrgProjectArg } from "../../../src/lib/arg-parsing.js"; +import { ContextError, ValidationError } from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; + +// --------------------------------------------------------------------------- +// parseDashboardPositionalArgs +// --------------------------------------------------------------------------- + +describe("parseDashboardPositionalArgs", () => { + test("throws ValidationError for empty args", () => { + expect(() => parseDashboardPositionalArgs([])).toThrow(ValidationError); + }); + + test("error message contains 'Dashboard ID or title'", () => { + try { + parseDashboardPositionalArgs([]); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).message).toContain( + "Dashboard ID or title" + ); + } + }); + + test("single arg returns dashboardRef only", () => { + const result = parseDashboardPositionalArgs(["123"]); + expect(result.dashboardRef).toBe("123"); + expect(result.targetArg).toBeUndefined(); + }); + + test("two args returns target + dashboardRef", () => { + const result = parseDashboardPositionalArgs(["my-org/", "My Dashboard"]); + expect(result.dashboardRef).toBe("My Dashboard"); + expect(result.targetArg).toBe("my-org/"); + }); +}); + +// --------------------------------------------------------------------------- +// resolveDashboardId +// --------------------------------------------------------------------------- + +describe("resolveDashboardId", () => { + let listDashboardsSpy: ReturnType<typeof spyOn>; + + beforeEach(() => { + listDashboardsSpy = spyOn(apiClient, "listDashboards"); + }); + + afterEach(() => { + listDashboardsSpy.mockRestore(); + }); + + test("numeric string returns directly without API call", async () => { + const id = await resolveDashboardId("test-org", "42"); + expect(id).toBe("42"); + expect(listDashboardsSpy).not.toHaveBeenCalled(); + }); + + test("title match returns matching dashboard ID", async () => { + listDashboardsSpy.mockResolvedValue([ + { id: "10", title: "Errors Overview" }, + { id: "20", title: "Performance" }, + ]); + + const id = await resolveDashboardId("test-org", "Performance"); + expect(id).toBe("20"); + }); + + test("title match is case-insensitive", async () => { + listDashboardsSpy.mockResolvedValue([ + { id: "10", title: "Errors Overview" }, + ]); + + const id = await resolveDashboardId("test-org", "errors overview"); + expect(id).toBe("10"); + }); + + test("no match throws ValidationError with available dashboards", async () => { + listDashboardsSpy.mockResolvedValue([ + { id: "10", title: "Errors Overview" }, + { id: "20", title: "Performance" }, + ]); + + try { + await resolveDashboardId("test-org", "Missing Dashboard"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const message = (error as ValidationError).message; + expect(message).toContain("Missing Dashboard"); + expect(message).toContain("Errors Overview"); + expect(message).toContain("Performance"); + } + }); +}); + +// --------------------------------------------------------------------------- +// resolveOrgFromTarget +// --------------------------------------------------------------------------- + +describe("resolveOrgFromTarget", () => { + let resolveOrgSpy: ReturnType<typeof spyOn>; + + beforeEach(() => { + resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + }); + + afterEach(() => { + resolveOrgSpy.mockRestore(); + }); + + test("explicit type returns org directly", async () => { + const parsed = parseOrgProjectArg("my-org/my-project"); + const org = await resolveOrgFromTarget( + parsed, + "/tmp", + "sentry dashboard view" + ); + expect(org).toBe("my-org"); + expect(resolveOrgSpy).not.toHaveBeenCalled(); + }); + + test("auto-detect with null resolveOrg throws ContextError", async () => { + resolveOrgSpy.mockResolvedValue(null); + const parsed = parseOrgProjectArg(undefined); + + await expect( + resolveOrgFromTarget(parsed, "/tmp", "sentry dashboard view") + ).rejects.toThrow(ContextError); + }); +}); diff --git a/test/lib/formatters/human.test.ts b/test/lib/formatters/human.test.ts index 88ec47b1f..ecdad7457 100644 --- a/test/lib/formatters/human.test.ts +++ b/test/lib/formatters/human.test.ts @@ -9,6 +9,8 @@ import { describe, expect, test } from "bun:test"; import { extractStatsPoints, + formatDashboardCreated, + formatDashboardView, formatIssueSubtitle, formatProjectCreated, formatShortId, @@ -672,3 +674,67 @@ describe("formatProjectCreated", () => { expect(result).toContain("sentry project view my-org/my-project"); }); }); + +describe("formatDashboardCreated", () => { + test("output contains title, ID, and URL", () => { + const result = stripAnsi( + formatDashboardCreated({ + id: "42", + title: "My Dashboard", + url: "https://acme.sentry.io/dashboard/42/", + }) + ); + expect(result).toContain("My Dashboard"); + expect(result).toContain("42"); + expect(result).toContain("https://acme.sentry.io/dashboard/42/"); + }); + + test("title with special chars is escaped", () => { + const result = stripAnsi( + formatDashboardCreated({ + id: "1", + title: "Dash | with * special", + url: "https://acme.sentry.io/dashboard/1/", + }) + ); + expect(result).toContain("Dash"); + expect(result).toContain("special"); + }); +}); + +describe("formatDashboardView", () => { + test("with widgets shows widget table headers", () => { + const result = stripAnsi( + formatDashboardView({ + id: "42", + title: "My Dashboard", + url: "https://acme.sentry.io/dashboard/42/", + widgets: [ + { + title: "Error Count", + displayType: "big_number", + widgetType: "spans", + layout: { x: 0, y: 0, w: 2, h: 1 }, + }, + ], + }) + ); + expect(result).toContain("TITLE"); + expect(result).toContain("DISPLAY"); + expect(result).toContain("TYPE"); + expect(result).toContain("LAYOUT"); + expect(result).toContain("Error Count"); + }); + + test("without widgets shows 'No widgets.'", () => { + const result = stripAnsi( + formatDashboardView({ + id: "42", + title: "Empty Dashboard", + url: "https://acme.sentry.io/dashboard/42/", + widgets: [], + }) + ); + expect(result).toContain("No widgets."); + }); +}); diff --git a/test/lib/sentry-urls.property.test.ts b/test/lib/sentry-urls.property.test.ts index 32f2c7afe..07428ed10 100644 --- a/test/lib/sentry-urls.property.test.ts +++ b/test/lib/sentry-urls.property.test.ts @@ -16,6 +16,8 @@ import { } from "fast-check"; import { buildBillingUrl, + buildDashboardsListUrl, + buildDashboardUrl, buildEventSearchUrl, buildLogsUrl, buildOrgSettingsUrl, @@ -74,6 +76,9 @@ const logIdArb = stringMatching(/^[a-f0-9]{32}$/); /** Valid trace IDs (32-char hex) */ const traceIdArb = stringMatching(/^[a-f0-9]{32}$/); +/** Valid dashboard IDs (numeric strings) */ +const dashboardIdArb = stringMatching(/^[1-9][0-9]{0,8}$/); + /** Common Sentry regions */ const sentryRegionArb = constantFrom("us", "de", "eu", "staging"); @@ -464,6 +469,71 @@ describe("buildTraceUrl properties", () => { }); }); +describe("buildDashboardsListUrl properties", () => { + test("output contains /dashboards/ path", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildDashboardsListUrl(orgSlug); + expect(result).toContain("/dashboards/"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output contains the org slug", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildDashboardsListUrl(orgSlug); + expect(result).toContain(orgSlug); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a valid URL", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildDashboardsListUrl(orgSlug); + expect(() => new URL(result)).not.toThrow(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("buildDashboardUrl properties", () => { + test("output contains /dashboard/{id}/ path", async () => { + await fcAssert( + property(tuple(slugArb, dashboardIdArb), ([orgSlug, dashboardId]) => { + const result = buildDashboardUrl(orgSlug, dashboardId); + expect(result).toContain(`/dashboard/${dashboardId}/`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output contains org slug and dashboard ID", async () => { + await fcAssert( + property(tuple(slugArb, dashboardIdArb), ([orgSlug, dashboardId]) => { + const result = buildDashboardUrl(orgSlug, dashboardId); + expect(result).toContain(orgSlug); + expect(result).toContain(dashboardId); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a valid URL", async () => { + await fcAssert( + property(tuple(slugArb, dashboardIdArb), ([orgSlug, dashboardId]) => { + const result = buildDashboardUrl(orgSlug, dashboardId); + expect(() => new URL(result)).not.toThrow(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + describe("SENTRY_HOST precedence", () => { test("SENTRY_HOST takes precedence over SENTRY_URL for URL builders", () => { process.env.SENTRY_HOST = "https://host.company.com"; @@ -518,6 +588,18 @@ describe("self-hosted URLs", () => { ); }); + test("buildDashboardsListUrl uses path-based pattern", () => { + expect(buildDashboardsListUrl("my-org")).toBe( + `${SELF_HOSTED_URL}/organizations/my-org/dashboards/` + ); + }); + + test("buildDashboardUrl uses path-based pattern", () => { + expect(buildDashboardUrl("my-org", "42")).toBe( + `${SELF_HOSTED_URL}/organizations/my-org/dashboard/42/` + ); + }); + test("buildProjectUrl uses path-based pattern", () => { expect(buildProjectUrl("my-org", "my-project")).toBe( `${SELF_HOSTED_URL}/settings/my-org/projects/my-project/` @@ -556,6 +638,8 @@ describe("self-hosted URLs", () => { buildBillingUrl(orgSlug), buildLogsUrl(orgSlug), buildTraceUrl(orgSlug, eventId), + buildDashboardsListUrl(orgSlug), + buildDashboardUrl(orgSlug, "1"), ]; for (const url of urls) { @@ -584,6 +668,8 @@ describe("URL building cross-function properties", () => { buildSeerSettingsUrl(orgSlug), buildBillingUrl(orgSlug), buildBillingUrl(orgSlug, product), + buildDashboardsListUrl(orgSlug), + buildDashboardUrl(orgSlug, "42"), ]; for (const url of urls) { @@ -616,6 +702,12 @@ describe("URL building cross-function properties", () => { expect(buildBillingUrl(orgSlug, product)).toBe( buildBillingUrl(orgSlug, product) ); + expect(buildDashboardsListUrl(orgSlug)).toBe( + buildDashboardsListUrl(orgSlug) + ); + expect(buildDashboardUrl(orgSlug, "42")).toBe( + buildDashboardUrl(orgSlug, "42") + ); } ), { numRuns: DEFAULT_NUM_RUNS } diff --git a/test/types/dashboard.test.ts b/test/types/dashboard.test.ts new file mode 100644 index 000000000..7b0ea6452 --- /dev/null +++ b/test/types/dashboard.test.ts @@ -0,0 +1,601 @@ +/** + * Dashboard Type & Validation Tests + * + * Tests for enum constants, strict input schema, and parseWidgetInput() + * in src/types/dashboard.ts. + */ + +import { describe, expect, test } from "bun:test"; +import { + assignDefaultLayout, + type DashboardWidget, + DashboardWidgetInputSchema, + DEFAULT_WIDGET_TYPE, + DISCOVER_AGGREGATE_FUNCTIONS, + DISPLAY_TYPES, + DiscoverAggregateFunctionSchema, + type DisplayType, + IS_FILTER_VALUES, + IsFilterValueSchema, + parseAggregate, + parseSortExpression, + parseWidgetInput, + prepareWidgetQueries, + SPAN_AGGREGATE_FUNCTIONS, + SpanAggregateFunctionSchema, + WIDGET_TYPES, + type WidgetType, +} from "../../src/types/dashboard.js"; + +// --------------------------------------------------------------------------- +// Enum constants +// --------------------------------------------------------------------------- + +describe("WIDGET_TYPES", () => { + test("contains spans as default", () => { + expect(WIDGET_TYPES).toContain("spans"); + expect(DEFAULT_WIDGET_TYPE).toBe("spans"); + }); + + test("contains all expected dataset types", () => { + const expected: WidgetType[] = [ + "discover", + "issue", + "metrics", + "error-events", + "transaction-like", + "spans", + "logs", + "tracemetrics", + "preprod-app-size", + ]; + for (const t of expected) { + expect(WIDGET_TYPES).toContain(t); + } + }); +}); + +describe("DISPLAY_TYPES", () => { + test("contains common visualization types", () => { + const common: DisplayType[] = [ + "line", + "area", + "bar", + "table", + "big_number", + ]; + for (const t of common) { + expect(DISPLAY_TYPES).toContain(t); + } + }); + + test("contains all expected display types", () => { + const expected: DisplayType[] = [ + "line", + "area", + "stacked_area", + "bar", + "table", + "big_number", + "top_n", + "details", + "categorical_bar", + "wheel", + "rage_and_dead_clicks", + "server_tree", + "text", + "agents_traces_table", + ]; + for (const t of expected) { + expect(DISPLAY_TYPES).toContain(t); + } + }); +}); + +// --------------------------------------------------------------------------- +// SPAN_AGGREGATE_FUNCTIONS / DISCOVER_AGGREGATE_FUNCTIONS +// --------------------------------------------------------------------------- + +describe("SPAN_AGGREGATE_FUNCTIONS", () => { + test("contains core aggregate functions", () => { + const core = [ + "count", + "avg", + "sum", + "min", + "max", + "p50", + "p75", + "p95", + "p99", + ]; + for (const fn of core) { + expect(SPAN_AGGREGATE_FUNCTIONS).toContain(fn); + } + }); + + test("contains rate functions and aliases", () => { + expect(SPAN_AGGREGATE_FUNCTIONS).toContain("eps"); + expect(SPAN_AGGREGATE_FUNCTIONS).toContain("epm"); + expect(SPAN_AGGREGATE_FUNCTIONS).toContain("sps"); + expect(SPAN_AGGREGATE_FUNCTIONS).toContain("spm"); + }); + + test("zod schema validates known functions", () => { + expect(SpanAggregateFunctionSchema.safeParse("count").success).toBe(true); + expect(SpanAggregateFunctionSchema.safeParse("p95").success).toBe(true); + }); + + test("zod schema rejects unknown functions", () => { + expect(SpanAggregateFunctionSchema.safeParse("bogus").success).toBe(false); + }); +}); + +describe("DISCOVER_AGGREGATE_FUNCTIONS", () => { + test("is a superset of span functions", () => { + for (const fn of SPAN_AGGREGATE_FUNCTIONS) { + expect(DISCOVER_AGGREGATE_FUNCTIONS).toContain(fn); + } + }); + + test("contains discover-specific functions", () => { + const extras = [ + "failure_count", + "failure_rate", + "apdex", + "user_misery", + "count_if", + "last_seen", + ]; + for (const fn of extras) { + expect(DISCOVER_AGGREGATE_FUNCTIONS).toContain(fn); + } + }); + + test("zod schema validates discover functions", () => { + expect(DiscoverAggregateFunctionSchema.safeParse("apdex").success).toBe( + true + ); + expect( + DiscoverAggregateFunctionSchema.safeParse("failure_rate").success + ).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// IS_FILTER_VALUES +// --------------------------------------------------------------------------- + +describe("IS_FILTER_VALUES", () => { + test("contains status values", () => { + const statuses = ["resolved", "unresolved", "ignored", "archived"]; + for (const s of statuses) { + expect(IS_FILTER_VALUES).toContain(s); + } + }); + + test("contains substatus values", () => { + const substatuses = ["escalating", "ongoing", "regressed", "new"]; + for (const s of substatuses) { + expect(IS_FILTER_VALUES).toContain(s); + } + }); + + test("contains assignment values", () => { + const assignments = [ + "assigned", + "unassigned", + "for_review", + "linked", + "unlinked", + ]; + for (const s of assignments) { + expect(IS_FILTER_VALUES).toContain(s); + } + }); + + test("zod schema validates known values", () => { + expect(IsFilterValueSchema.safeParse("unresolved").success).toBe(true); + expect(IsFilterValueSchema.safeParse("escalating").success).toBe(true); + expect(IsFilterValueSchema.safeParse("assigned").success).toBe(true); + }); + + test("zod schema rejects unknown values", () => { + expect(IsFilterValueSchema.safeParse("bogus").success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// DashboardWidgetInputSchema +// --------------------------------------------------------------------------- + +describe("DashboardWidgetInputSchema", () => { + const minimalWidget = { + title: "My Widget", + displayType: "line", + }; + + test("accepts minimal widget and defaults widgetType to spans", () => { + const result = DashboardWidgetInputSchema.safeParse(minimalWidget); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.widgetType).toBe("spans"); + } + }); + + test("accepts explicit widgetType", () => { + const result = DashboardWidgetInputSchema.safeParse({ + ...minimalWidget, + widgetType: "error-events", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.widgetType).toBe("error-events"); + } + }); + + test("accepts all valid widgetType values", () => { + for (const wt of WIDGET_TYPES) { + const result = DashboardWidgetInputSchema.safeParse({ + ...minimalWidget, + widgetType: wt, + }); + expect(result.success).toBe(true); + } + }); + + test("accepts all valid displayType values", () => { + for (const dt of DISPLAY_TYPES) { + const result = DashboardWidgetInputSchema.safeParse({ + title: "Test", + displayType: dt, + }); + expect(result.success).toBe(true); + } + }); + + test("rejects invalid displayType", () => { + const result = DashboardWidgetInputSchema.safeParse({ + ...minimalWidget, + displayType: "chart", + }); + expect(result.success).toBe(false); + }); + + test("rejects invalid widgetType", () => { + const result = DashboardWidgetInputSchema.safeParse({ + ...minimalWidget, + widgetType: "span", + }); + expect(result.success).toBe(false); + }); + + test("rejects missing title", () => { + const result = DashboardWidgetInputSchema.safeParse({ + displayType: "line", + }); + expect(result.success).toBe(false); + }); + + test("rejects missing displayType", () => { + const result = DashboardWidgetInputSchema.safeParse({ + title: "My Widget", + }); + expect(result.success).toBe(false); + }); + + test("preserves extra fields via passthrough", () => { + const result = DashboardWidgetInputSchema.safeParse({ + ...minimalWidget, + customField: "hello", + }); + expect(result.success).toBe(true); + if (result.success) { + expect((result.data as Record<string, unknown>).customField).toBe( + "hello" + ); + } + }); + + test("accepts widget with queries", () => { + const result = DashboardWidgetInputSchema.safeParse({ + ...minimalWidget, + queries: [ + { + conditions: "transaction.op:http", + aggregates: ["count()"], + columns: [], + }, + ], + }); + expect(result.success).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// parseWidgetInput +// --------------------------------------------------------------------------- + +describe("parseWidgetInput", () => { + test("returns validated widget with defaults", () => { + const widget = parseWidgetInput({ + title: "Error Count", + displayType: "big_number", + }); + expect(widget.title).toBe("Error Count"); + expect(widget.displayType).toBe("big_number"); + expect(widget.widgetType).toBe("spans"); + }); + + test("preserves explicit widgetType", () => { + const widget = parseWidgetInput({ + title: "Errors", + displayType: "line", + widgetType: "error-events", + }); + expect(widget.widgetType).toBe("error-events"); + }); + + test("throws ValidationError for invalid displayType with valid values listed", () => { + expect(() => + parseWidgetInput({ + title: "Bad Widget", + displayType: "invalid_chart", + }) + ).toThrow(/Invalid displayType/); + expect(() => + parseWidgetInput({ + title: "Bad Widget", + displayType: "invalid_chart", + }) + ).toThrow(/line/); + }); + + test("throws ValidationError for invalid widgetType with valid values listed", () => { + expect(() => + parseWidgetInput({ + title: "Bad Widget", + displayType: "line", + widgetType: "span", + }) + ).toThrow(/Invalid widgetType/); + expect(() => + parseWidgetInput({ + title: "Bad Widget", + displayType: "line", + widgetType: "span", + }) + ).toThrow(/spans/); + }); + + test("throws ValidationError for missing required fields", () => { + expect(() => parseWidgetInput({})).toThrow(/Invalid widget definition/); + }); + + test("throws ValidationError for non-object input", () => { + expect(() => parseWidgetInput("not an object")).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// parseAggregate +// --------------------------------------------------------------------------- + +describe("parseAggregate", () => { + test("bare name becomes no-arg function call", () => { + expect(parseAggregate("count")).toBe("count()"); + }); + + test("colon syntax becomes function with arg", () => { + expect(parseAggregate("p95:span.duration")).toBe("p95(span.duration)"); + }); + + test("passthrough when already has parens", () => { + expect(parseAggregate("count()")).toBe("count()"); + }); + + test("passthrough for function with args in parens", () => { + expect(parseAggregate("avg(span.self_time)")).toBe("avg(span.self_time)"); + }); + + test("colon with dotted column name", () => { + expect(parseAggregate("avg:span.self_time")).toBe("avg(span.self_time)"); + }); + + test("single word functions", () => { + expect(parseAggregate("p50")).toBe("p50()"); + expect(parseAggregate("p75")).toBe("p75()"); + expect(parseAggregate("p99")).toBe("p99()"); + }); +}); + +// --------------------------------------------------------------------------- +// parseSortExpression +// --------------------------------------------------------------------------- + +describe("parseSortExpression", () => { + test("ascending bare name", () => { + expect(parseSortExpression("count")).toBe("count()"); + }); + + test("descending bare name", () => { + expect(parseSortExpression("-count")).toBe("-count()"); + }); + + test("ascending colon syntax", () => { + expect(parseSortExpression("p95:span.duration")).toBe("p95(span.duration)"); + }); + + test("descending colon syntax", () => { + expect(parseSortExpression("-p95:span.duration")).toBe( + "-p95(span.duration)" + ); + }); + + test("passthrough with parens", () => { + expect(parseSortExpression("count()")).toBe("count()"); + expect(parseSortExpression("-count()")).toBe("-count()"); + }); +}); + +// --------------------------------------------------------------------------- +// prepareWidgetQueries +// --------------------------------------------------------------------------- + +describe("prepareWidgetQueries", () => { + test("auto-computes fields from aggregates + columns", () => { + const widget = prepareWidgetQueries({ + title: "Test", + displayType: "line", + queries: [ + { + aggregates: ["count()"], + columns: ["browser.name"], + }, + ], + }); + expect(widget.queries?.[0]?.fields).toEqual(["browser.name", "count()"]); + }); + + test("does not overwrite existing fields", () => { + const widget = prepareWidgetQueries({ + title: "Test", + displayType: "line", + queries: [ + { + aggregates: ["count()"], + columns: ["browser.name"], + fields: ["custom_field"], + }, + ], + }); + expect(widget.queries?.[0]?.fields).toEqual(["custom_field"]); + }); + + test("defaults conditions to empty string when missing", () => { + const widget = prepareWidgetQueries({ + title: "Test", + displayType: "line", + queries: [{ aggregates: ["count()"] }], + }); + expect(widget.queries?.[0]?.conditions).toBe(""); + }); + + test("preserves existing conditions", () => { + const widget = prepareWidgetQueries({ + title: "Test", + displayType: "line", + queries: [ + { + aggregates: ["count()"], + conditions: "is:unresolved", + }, + ], + }); + expect(widget.queries?.[0]?.conditions).toBe("is:unresolved"); + }); + + test("handles widget with no queries", () => { + const widget = prepareWidgetQueries({ + title: "Test", + displayType: "big_number", + }); + expect(widget.queries).toBeUndefined(); + }); + + test("handles empty aggregates and columns", () => { + const widget = prepareWidgetQueries({ + title: "Test", + displayType: "line", + queries: [{}], + }); + expect(widget.queries?.[0]?.fields).toEqual([]); + expect(widget.queries?.[0]?.conditions).toBe(""); + }); + + test("throws for table widget with limit exceeding max", () => { + expect(() => + prepareWidgetQueries({ + title: "Test", + displayType: "table", + limit: 25, + }) + ).toThrow(/maximum limit for table widgets is 10/); + }); + + test("throws for bar widget with limit exceeding max", () => { + expect(() => + prepareWidgetQueries({ + title: "Test", + displayType: "bar", + limit: 15, + }) + ).toThrow(/maximum limit for bar widgets is 10/); + }); + + test("accepts table widget with limit within max", () => { + const widget = prepareWidgetQueries({ + title: "Test", + displayType: "table", + limit: 5, + }); + expect(widget.limit).toBe(5); + }); + + test("accepts line widget with any limit", () => { + const widget = prepareWidgetQueries({ + title: "Test", + displayType: "line", + limit: 100, + }); + expect(widget.limit).toBe(100); + }); +}); + +// --------------------------------------------------------------------------- +// assignDefaultLayout +// --------------------------------------------------------------------------- + +describe("assignDefaultLayout", () => { + test("widget with existing layout returns unchanged", () => { + const widget: DashboardWidget = { + title: "Test", + displayType: "line", + layout: { x: 1, y: 2, w: 3, h: 2 }, + }; + const result = assignDefaultLayout(widget, []); + expect(result.layout).toEqual({ x: 1, y: 2, w: 3, h: 2 }); + }); + + test("widget without layout assigns default size at (0,0)", () => { + const widget: DashboardWidget = { + title: "Test", + displayType: "big_number", + }; + const result = assignDefaultLayout(widget, []); + expect(result.layout).toBeDefined(); + expect(result.layout!.x).toBe(0); + expect(result.layout!.y).toBe(0); + expect(result.layout!.w).toBe(2); + expect(result.layout!.h).toBe(1); + }); + + test("widget in partially filled grid finds first gap", () => { + const existing: DashboardWidget[] = [ + { + title: "Existing", + displayType: "big_number", + layout: { x: 0, y: 0, w: 2, h: 1 }, + }, + ]; + const widget: DashboardWidget = { + title: "New", + displayType: "big_number", + }; + const result = assignDefaultLayout(widget, existing); + expect(result.layout).toBeDefined(); + // Should be placed after the existing widget, not overlapping + expect(result.layout!.x).toBe(2); + expect(result.layout!.y).toBe(0); + }); +});