From 91455f222aa0cf500ed41f6f4f0b18b425c11363 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 10 Feb 2026 16:17:28 +0100 Subject: [PATCH 1/7] refactor(project): replace --org flag with org/project positional arg project view now accepts / as a single positional argument instead of separate --org flag and positional, matching the pattern used by event view and issue view. Auto-detect via DSN still works when no argument is provided, including monorepo multi-target. --- src/commands/project/list.ts | 4 +- src/commands/project/view.ts | 141 ++++++++++++++++++++++++++--------- src/lib/resolve-issue.ts | 6 +- src/lib/resolve-target.ts | 8 +- 4 files changed, 115 insertions(+), 44 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index b22aa124..01aa11f3 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -150,7 +150,7 @@ async function resolveOrgsToFetch( orgFlag: string | undefined, cwd: string ): Promise { - // 1. If --org flag provided, use it directly + // 1. If org positional provided, use it directly if (orgFlag) { return { orgs: [orgFlag] }; } @@ -309,7 +309,7 @@ export const listCommand = buildCommand({ writeFooter( stdout, - "Tip: Use 'sentry project view --org ' for details" + "Tip: Use 'sentry project view /' for details" ); }, }); diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 8c5208e7..6f1c4be2 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -6,10 +6,18 @@ */ import type { SentryContext } from "../../context.js"; -import { getProject, getProjectKeys } from "../../lib/api-client.js"; +import { + findProjectsBySlug, + getProject, + getProjectKeys, +} from "../../lib/api-client.js"; +import { + ProjectSpecificationType, + parseOrgProjectArg, +} from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { AuthError, ContextError } from "../../lib/errors.js"; +import { AuthError, ContextError, ValidationError } from "../../lib/errors.js"; import { divider, formatProjectDetails, @@ -24,27 +32,27 @@ import { buildProjectUrl } from "../../lib/sentry-urls.js"; import type { ProjectKey, SentryProject } from "../../types/index.js"; type ViewFlags = { - readonly org?: string; readonly json: boolean; readonly web: boolean; }; +/** Usage hint for ContextError messages */ +const USAGE_HINT = "sentry project view /"; + /** * Build an error message for missing context, with optional DSN resolution hint. */ function buildContextError(skippedSelfHosted?: number): ContextError { - const usageHint = "sentry project view --org "; - if (skippedSelfHosted) { return new ContextError( "Organization and project", - `${usageHint}\n\n` + + `${USAGE_HINT}\n\n` + `Note: Found ${skippedSelfHosted} DSN(s) that could not be resolved.\n` + "You may not have access to these projects, or specify the target explicitly." ); } - return new ContextError("Organization and project", usageHint); + return new ContextError("Organization and project", USAGE_HINT); } /** @@ -58,7 +66,7 @@ async function handleWebView( if (resolvedTargets.length > 1) { throw new ContextError( "Single project", - "sentry project view --org -w\n\n" + + `${USAGE_HINT} -w\n\n` + `Found ${resolvedTargets.length} projects. Specify which project to open in browser.` ); } @@ -191,15 +199,53 @@ function writeMultipleProjects( } } +/** + * Resolve target from a project search result. + * + * Searches for a project by slug across all accessible organizations. + * Throws if no project found or if multiple projects found in different orgs. + * + * @param projectSlug - Project slug to search for + * @returns Resolved target with org and project info + * @throws {ContextError} If no project found + * @throws {ValidationError} If project exists in multiple organizations + */ +async function resolveFromProjectSearch( + projectSlug: string +): Promise { + const found = await findProjectsBySlug(projectSlug); + if (found.length === 0) { + throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ + "Check that you have access to a project with this slug", + ]); + } + if (found.length > 1) { + const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); + throw new ValidationError( + `Project "${projectSlug}" exists in multiple organizations.\n\n` + + `Specify the organization:\n${orgList}\n\n` + + `Example: sentry project view /${projectSlug}` + ); + } + // Safe assertion: length is exactly 1 after the checks above + const foundProject = found[0] as (typeof found)[0]; + return { + org: foundProject.orgSlug, + project: foundProject.slug, + orgDisplay: foundProject.orgSlug, + projectDisplay: foundProject.slug, + }; +} + export const viewCommand = buildCommand({ docs: { brief: "View details of a project", fullDescription: "View detailed information about Sentry projects.\n\n" + - "The organization and project are resolved from:\n" + - " 1. Positional argument and --org flag\n" + - " 2. Config defaults\n" + - " 3. SENTRY_DSN environment variable or source code detection\n\n" + + "Target specification:\n" + + " sentry project view # auto-detect from DSN or config\n" + + " sentry project view / # explicit org and project\n" + + " sentry project view # find project across all orgs\n\n" + "In monorepos with multiple Sentry projects, shows details for all detected projects.", }, parameters: { @@ -207,20 +253,14 @@ export const viewCommand = buildCommand({ kind: "tuple", parameters: [ { - placeholder: "project", - brief: "Project slug (optional if auto-detected)", + placeholder: "target", + brief: "Target: /, , or omit for auto-detect", parse: String, optional: true, }, ], }, flags: { - org: { - kind: "parsed", - parse: String, - brief: "Organization slug", - optional: true, - }, json: { kind: "boolean", brief: "Output as JSON", @@ -237,24 +277,55 @@ export const viewCommand = buildCommand({ async func( this: SentryContext, flags: ViewFlags, - projectSlug?: string + targetArg?: string ): Promise { const { stdout, cwd } = this; - // Resolve targets (may find multiple in monorepos) - const { - targets: resolvedTargets, - footer, - skippedSelfHosted, - } = await resolveAllTargets({ - org: flags.org, - project: projectSlug, - cwd, - usageHint: "sentry project view --org ", - }); - - if (resolvedTargets.length === 0) { - throw buildContextError(skippedSelfHosted); + const parsed = parseOrgProjectArg(targetArg); + + let resolvedTargets: ResolvedTarget[]; + let footer: string | undefined; + + switch (parsed.type) { + case ProjectSpecificationType.Explicit: + // Direct org/project - single target, no multi-target resolution + resolvedTargets = [ + { + org: parsed.org, + project: parsed.project, + orgDisplay: parsed.org, + projectDisplay: parsed.project, + }, + ]; + break; + + case ProjectSpecificationType.ProjectSearch: + // Search for project across all orgs - single target + resolvedTargets = [await resolveFromProjectSearch(parsed.projectSlug)]; + break; + + case ProjectSpecificationType.OrgAll: + throw new ContextError( + "Specific project", + `${USAGE_HINT}\n\n` + + "Specify the full org/project target, not just the organization." + ); + + case ProjectSpecificationType.AutoDetect: { + // Auto-detect supports monorepo multi-target resolution + const result = await resolveAllTargets({ cwd }); + + if (result.targets.length === 0) { + throw buildContextError(result.skippedSelfHosted); + } + + resolvedTargets = result.targets; + footer = result.footer; + break; + } + + default: + throw new ValidationError("Invalid target specification"); } if (flags.web) { diff --git a/src/lib/resolve-issue.ts b/src/lib/resolve-issue.ts index 9f252956..4d31ef17 100644 --- a/src/lib/resolve-issue.ts +++ b/src/lib/resolve-issue.ts @@ -26,9 +26,9 @@ import { resolveOrg, resolveOrgAndProject } from "./resolve-target.js"; * Options for resolving an issue ID. */ export type ResolveIssueOptions = { - /** Organization slug from --org flag */ + /** Organization slug */ org?: string; - /** Project slug from --project flag (needed for short suffix resolution) */ + /** Project slug (needed for short suffix resolution) */ project?: string; /** Current working directory for DSN detection and alias cache */ cwd: string; @@ -135,7 +135,7 @@ async function resolveWithOrgContext( * * @param input - User-provided issue ID in any supported format * @param options - Resolution options with org/project flags and cwd - * @param commandHint - Command example for error messages (e.g., "sentry issue view ID --org ") + * @param commandHint - Command example for error messages (e.g., "sentry issue view /ID") * @returns Resolved organization and issue ID * @throws {ContextError} When required context (org/project) cannot be resolved * diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 3c503f3f..b87d4877 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -81,9 +81,9 @@ export type ResolvedOrg = { * Options for resolving org and project. */ export type ResolveOptions = { - /** Organization slug from CLI flag */ + /** Organization slug */ org?: string; - /** Project slug from CLI flag */ + /** Project slug */ project?: string; /** Current working directory for DSN detection */ cwd: string; @@ -95,7 +95,7 @@ export type ResolveOptions = { * Options for resolving org only. */ export type ResolveOrgOptions = { - /** Organization slug from CLI flag */ + /** Organization slug */ org?: string; /** Current working directory for DSN detection */ cwd: string; @@ -650,7 +650,7 @@ export async function resolveOrgAndProject( * Resolve organization only from multiple sources. * * Resolution priority: - * 1. CLI flag (--org) + * 1. Positional argument * 2. Config defaults * 3. DSN auto-detection * From 50a0bfa52221b6ddf088acc8c72c2986f4ca0f63 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 10 Feb 2026 16:17:32 +0100 Subject: [PATCH 2/7] test(project): update tests for org/project positional syntax E2E tests now pass org/project as a single arg instead of --org flag. Renamed test cases to reflect positional args instead of flags. --- test/e2e/project.test.ts | 34 ++++++++-------------------- test/isolated/resolve-target.test.ts | 6 ++--- test/lib/errors.test.ts | 4 ++-- 3 files changed, 15 insertions(+), 29 deletions(-) diff --git a/test/e2e/project.test.ts b/test/e2e/project.test.ts index 68eabe4d..15506698 100644 --- a/test/e2e/project.test.ts +++ b/test/e2e/project.test.ts @@ -189,9 +189,7 @@ describe("sentry project view", () => { const result = await ctx.run([ "project", "view", - TEST_PROJECT, - "--org", - TEST_ORG, + `${TEST_ORG}/${TEST_PROJECT}`, ]); expect(result.exitCode).toBe(1); @@ -207,18 +205,16 @@ describe("sentry project view", () => { expect(result.stderr + result.stdout).toMatch(/organization|project/i); }); - test("rejects partial flags (--org without project)", async () => { + test("rejects org-only target (org/)", async () => { await ctx.setAuthToken(TEST_TOKEN); - const result = await ctx.run(["project", "view", "--org", TEST_ORG]); + const result = await ctx.run(["project", "view", `${TEST_ORG}/`]); expect(result.exitCode).toBe(1); // Should show error with usage hint const output = result.stderr + result.stdout; - expect(output).toMatch(/organization and project is required/i); - expect(output).toContain( - "sentry project view --org " - ); + expect(output).toMatch(/specific project is required/i); + expect(output).toContain("sentry project view /"); }); test( @@ -229,9 +225,7 @@ describe("sentry project view", () => { const result = await ctx.run([ "project", "view", - TEST_PROJECT, - "--org", - TEST_ORG, + `${TEST_ORG}/${TEST_PROJECT}`, ]); expect(result.exitCode).toBe(0); @@ -249,9 +243,7 @@ describe("sentry project view", () => { const result = await ctx.run([ "project", "view", - TEST_PROJECT, - "--org", - TEST_ORG, + `${TEST_ORG}/${TEST_PROJECT}`, ]); expect(result.exitCode).toBe(0); @@ -269,9 +261,7 @@ describe("sentry project view", () => { const result = await ctx.run([ "project", "view", - TEST_PROJECT, - "--org", - TEST_ORG, + `${TEST_ORG}/${TEST_PROJECT}`, "--json", ]); @@ -290,9 +280,7 @@ describe("sentry project view", () => { const result = await ctx.run([ "project", "view", - TEST_PROJECT, - "--org", - TEST_ORG, + `${TEST_ORG}/${TEST_PROJECT}`, "--json", ]); @@ -311,9 +299,7 @@ describe("sentry project view", () => { const result = await ctx.run([ "project", "view", - "nonexistent-project-12345", - "--org", - TEST_ORG, + `${TEST_ORG}/nonexistent-project-12345`, ]); expect(result.exitCode).toBe(1); diff --git a/test/isolated/resolve-target.test.ts b/test/isolated/resolve-target.test.ts index 35f1ef21..07d94cbf 100644 --- a/test/isolated/resolve-target.test.ts +++ b/test/isolated/resolve-target.test.ts @@ -405,13 +405,13 @@ describe("resolveOrgAndProject", () => { expect(mockDetectDsn).not.toHaveBeenCalled(); }); - test("throws ContextError when only org flag provided", async () => { + test("throws ContextError when only org provided", async () => { await expect( resolveOrgAndProject({ org: "my-org", cwd: "/test" }) ).rejects.toThrow(ContextError); }); - test("throws ContextError when only project flag provided", async () => { + test("throws ContextError when only project provided", async () => { await expect( resolveOrgAndProject({ project: "my-project", cwd: "/test" }) ).rejects.toThrow(ContextError); @@ -519,7 +519,7 @@ describe("resolveAllTargets", () => { expect(result.targets[0].project).toBe("my-project"); }); - test("throws ContextError when only org flag provided", async () => { + test("throws ContextError when only org provided", async () => { await expect( resolveAllTargets({ org: "my-org", cwd: "/test" }) ).rejects.toThrow(ContextError); diff --git a/test/lib/errors.test.ts b/test/lib/errors.test.ts index 27f69c0f..8f13e515 100644 --- a/test/lib/errors.test.ts +++ b/test/lib/errors.test.ts @@ -112,11 +112,11 @@ describe("ContextError", () => { test("format() includes custom alternatives", () => { const err = new ContextError("Project", "sentry project list", [ - "Specify --project flag", + "Specify project in / format", ]); const formatted = err.format(); expect(formatted).toContain("Project is required."); - expect(formatted).toContain("Specify --project flag"); + expect(formatted).toContain("Specify project in / format"); expect(formatted).not.toContain("SENTRY_DSN"); }); From cace0295b1786f6f5fc7688c5fa91b93b90aaa9b Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 10 Feb 2026 16:17:35 +0100 Subject: [PATCH 3/7] docs(project): update project view docs for org/project syntax --- docs/src/content/docs/commands/project.md | 16 +++++++++++----- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 15 ++++++++++----- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/src/content/docs/commands/project.md b/docs/src/content/docs/commands/project.md index b43729a5..e8e37a66 100644 --- a/docs/src/content/docs/commands/project.md +++ b/docs/src/content/docs/commands/project.md @@ -49,27 +49,33 @@ my-org mobile-ios cocoa mobile-team View details of a specific project. ```bash -sentry project view +# Auto-detect from DSN or config +sentry project view + +# Explicit org and project +sentry project view / + +# Find project across all orgs +sentry project view ``` **Arguments:** | Argument | Description | |----------|-------------| -| `` | The project slug | +| `[target]` | Optional: `/`, ``, or omit for auto-detect | **Options:** | Option | Description | |--------|-------------| -| `--org ` | Organization slug (if not specified, uses default) | | `-w, --web` | Open in browser | | `--json` | Output as JSON | **Example:** ```bash -sentry project view frontend --org my-org +sentry project view my-org/frontend ``` ``` @@ -83,5 +89,5 @@ DSN: https://abc123@sentry.io/123456 **Open in browser:** ```bash -sentry project view frontend -w +sentry project view my-org/frontend -w ``` diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index f4022d3b..7e62eda4 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -158,23 +158,28 @@ sentry project list sentry project list --platform javascript ``` -#### `sentry project view ` +#### `sentry project view [/]` View details of a project **Flags:** -- `--org - Organization slug` - `--json - Output as JSON` - `-w, --web - Open in browser` **Examples:** ```bash -sentry project view +# Auto-detect from DSN or config +sentry project view + +# Explicit org and project +sentry project view my-org/frontend -sentry project view frontend --org my-org +# Find project across all orgs +sentry project view frontend -sentry project view frontend -w +# Open in browser +sentry project view my-org/frontend -w ``` ### Issue From 9da8df4158bcf9e1236bba7d3cff9152c902d101 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 10 Feb 2026 16:30:43 +0100 Subject: [PATCH 4/7] refactor: extract resolveProjectBySlug into shared utility Deduplicate resolveFromProjectSearch() which was identically implemented in event/view, trace/view, log/view, and project/view. The shared resolveProjectBySlug() in resolve-target.ts handles project slug lookup with consistent error messages and disambiguation prompts. --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 9 +-- src/commands/event/view.ts | 67 ++++++------------- src/commands/log/view.ts | 53 +++------------ src/commands/project/view.ts | 65 +++++------------- src/commands/trace/view.ts | 53 +++------------ src/lib/resolve-target.ts | 45 ++++++++++++- test/commands/event/view.test.ts | 47 +++++++------ test/commands/log/view.test.ts | 43 +++++++----- test/commands/trace/view.test.ts | 33 ++++----- 9 files changed, 174 insertions(+), 241 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 7e62eda4..0a00b3c8 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -158,7 +158,7 @@ sentry project list sentry project list --platform javascript ``` -#### `sentry project view [/]` +#### `sentry project view ` View details of a project @@ -173,12 +173,13 @@ View details of a project sentry project view # Explicit org and project -sentry project view my-org/frontend +sentry project view / # Find project across all orgs -sentry project view frontend +sentry project view + +sentry project view my-org/frontend -# Open in browser sentry project view my-org/frontend -w ``` diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index be8b273e..c257b5f6 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -5,7 +5,7 @@ */ import type { SentryContext } from "../../context.js"; -import { findProjectsBySlug, getEvent } from "../../lib/api-client.js"; +import { getEvent } from "../../lib/api-client.js"; import { ProjectSpecificationType, parseOrgProjectArg, @@ -13,9 +13,12 @@ import { } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { ContextError, ValidationError } from "../../lib/errors.js"; +import { ContextError } from "../../lib/errors.js"; import { formatEventDetails, writeJson } from "../../lib/formatters/index.js"; -import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { + resolveOrgAndProject, + resolveProjectBySlug, +} from "../../lib/resolve-target.js"; import { buildEventSearchUrl } from "../../lib/sentry-urls.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; import type { SentryEvent, Writer } from "../../types/index.js"; @@ -105,48 +108,6 @@ export type ResolvedEventTarget = { detectedFrom?: string; }; -/** - * Resolve target from a project search result. - * - * Searches for a project by slug across all accessible organizations. - * Throws if no project found or if multiple projects found in different orgs. - * - * @param projectSlug - Project slug to search for - * @param eventId - Event ID (used in error messages) - * @returns Resolved target with org and project info - * @throws {ContextError} If no project found - * @throws {ValidationError} If project exists in multiple organizations - * - * @internal Exported for testing - */ -export async function resolveFromProjectSearch( - projectSlug: string, - eventId: string -): Promise { - const found = await findProjectsBySlug(projectSlug); - if (found.length === 0) { - throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ - "Check that you have access to a project with this slug", - ]); - } - if (found.length > 1) { - const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); - throw new ValidationError( - `Project "${projectSlug}" exists in multiple organizations.\n\n` + - `Specify the organization:\n${orgList}\n\n` + - `Example: sentry event view /${projectSlug} ${eventId}` - ); - } - // Safe assertion: length is exactly 1 after the checks above - const foundProject = found[0] as (typeof found)[0]; - return { - org: foundProject.orgSlug, - project: foundProject.slug, - orgDisplay: foundProject.orgSlug, - projectDisplay: foundProject.slug, - }; -} - export const viewCommand = buildCommand({ docs: { brief: "View details of a specific event", @@ -205,9 +166,19 @@ export const viewCommand = buildCommand({ }; break; - case ProjectSpecificationType.ProjectSearch: - target = await resolveFromProjectSearch(parsed.projectSlug, eventId); + case ProjectSpecificationType.ProjectSearch: { + const resolved = await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry event view /${parsed.projectSlug} ${eventId}` + ); + target = { + ...resolved, + orgDisplay: resolved.org, + projectDisplay: resolved.project, + }; break; + } case ProjectSpecificationType.OrgAll: throw new ContextError("Specific project", USAGE_HINT); @@ -218,7 +189,7 @@ export const viewCommand = buildCommand({ default: // Exhaustive check - should never reach here - throw new ValidationError("Invalid target specification"); + throw new ContextError("Organization and project", USAGE_HINT); } if (!target) { diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 3720a804..36430ccd 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -6,12 +6,15 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { findProjectsBySlug, getLog } from "../../lib/api-client.js"; +import { getLog } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatLogDetails, writeJson } from "../../lib/formatters/index.js"; -import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { + resolveOrgAndProject, + resolveProjectBySlug, +} from "../../lib/resolve-target.js"; import { buildLogsUrl } from "../../lib/sentry-urls.js"; import type { DetailedSentryLog, Writer } from "../../types/index.js"; @@ -68,46 +71,6 @@ export type ResolvedLogTarget = { detectedFrom?: string; }; -/** - * Resolve target from a project search result. - * - * Searches for a project by slug across all accessible organizations. - * Throws if no project found or if multiple projects found in different orgs. - * - * @param projectSlug - Project slug to search for - * @param logId - Log ID (used in error messages) - * @returns Resolved target with org and project info - * @throws {ContextError} If no project found - * @throws {ValidationError} If project exists in multiple organizations - * - * @internal Exported for testing - */ -export async function resolveFromProjectSearch( - projectSlug: string, - logId: string -): Promise { - const found = await findProjectsBySlug(projectSlug); - if (found.length === 0) { - throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ - "Check that you have access to a project with this slug", - ]); - } - if (found.length > 1) { - const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); - throw new ValidationError( - `Project "${projectSlug}" exists in multiple organizations.\n\n` + - `Specify the organization:\n${orgList}\n\n` + - `Example: sentry log view /${projectSlug} ${logId}` - ); - } - // Safe assertion: length is exactly 1 after the checks above - const foundProject = found[0] as (typeof found)[0]; - return { - org: foundProject.orgSlug, - project: foundProject.slug, - }; -} - /** * Write human-readable log output to stdout. * @@ -187,7 +150,11 @@ export const viewCommand = buildCommand({ break; case "project-search": - target = await resolveFromProjectSearch(parsed.projectSlug, logId); + target = await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry log view /${parsed.projectSlug} ${logId}` + ); break; case "org-all": diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 6f1c4be2..8d24db38 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -6,18 +6,14 @@ */ import type { SentryContext } from "../../context.js"; -import { - findProjectsBySlug, - getProject, - getProjectKeys, -} from "../../lib/api-client.js"; +import { getProject, getProjectKeys } from "../../lib/api-client.js"; import { ProjectSpecificationType, parseOrgProjectArg, } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { AuthError, ContextError, ValidationError } from "../../lib/errors.js"; +import { AuthError, ContextError } from "../../lib/errors.js"; import { divider, formatProjectDetails, @@ -27,6 +23,7 @@ import { import { type ResolvedTarget, resolveAllTargets, + resolveProjectBySlug, } from "../../lib/resolve-target.js"; import { buildProjectUrl } from "../../lib/sentry-urls.js"; import type { ProjectKey, SentryProject } from "../../types/index.js"; @@ -199,44 +196,6 @@ function writeMultipleProjects( } } -/** - * Resolve target from a project search result. - * - * Searches for a project by slug across all accessible organizations. - * Throws if no project found or if multiple projects found in different orgs. - * - * @param projectSlug - Project slug to search for - * @returns Resolved target with org and project info - * @throws {ContextError} If no project found - * @throws {ValidationError} If project exists in multiple organizations - */ -async function resolveFromProjectSearch( - projectSlug: string -): Promise { - const found = await findProjectsBySlug(projectSlug); - if (found.length === 0) { - throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ - "Check that you have access to a project with this slug", - ]); - } - if (found.length > 1) { - const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); - throw new ValidationError( - `Project "${projectSlug}" exists in multiple organizations.\n\n` + - `Specify the organization:\n${orgList}\n\n` + - `Example: sentry project view /${projectSlug}` - ); - } - // Safe assertion: length is exactly 1 after the checks above - const foundProject = found[0] as (typeof found)[0]; - return { - org: foundProject.orgSlug, - project: foundProject.slug, - orgDisplay: foundProject.orgSlug, - projectDisplay: foundProject.slug, - }; -} - export const viewCommand = buildCommand({ docs: { brief: "View details of a project", @@ -299,10 +258,22 @@ export const viewCommand = buildCommand({ ]; break; - case ProjectSpecificationType.ProjectSearch: + case ProjectSpecificationType.ProjectSearch: { // Search for project across all orgs - single target - resolvedTargets = [await resolveFromProjectSearch(parsed.projectSlug)]; + const resolved = await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry project view /${parsed.projectSlug}` + ); + resolvedTargets = [ + { + ...resolved, + orgDisplay: resolved.org, + projectDisplay: resolved.project, + }, + ]; break; + } case ProjectSpecificationType.OrgAll: throw new ContextError( @@ -325,7 +296,7 @@ export const viewCommand = buildCommand({ } default: - throw new ValidationError("Invalid target specification"); + throw new ContextError("Organization and project", USAGE_HINT); } if (flags.web) { diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 238db359..f9ca92b2 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -6,7 +6,7 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { findProjectsBySlug, getDetailedTrace } from "../../lib/api-client.js"; +import { getDetailedTrace } from "../../lib/api-client.js"; import { parseOrgProjectArg, spansFlag } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; @@ -17,7 +17,10 @@ import { writeFooter, writeJson, } from "../../lib/formatters/index.js"; -import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { + resolveOrgAndProject, + resolveProjectBySlug, +} from "../../lib/resolve-target.js"; import { buildTraceUrl } from "../../lib/sentry-urls.js"; import type { Writer } from "../../types/index.js"; @@ -75,46 +78,6 @@ export type ResolvedTraceTarget = { detectedFrom?: string; }; -/** - * Resolve target from a project search result. - * - * Searches for a project by slug across all accessible organizations. - * Throws if no project found or if multiple projects found in different orgs. - * - * @param projectSlug - Project slug to search for - * @param traceId - Trace ID (used in error messages) - * @returns Resolved target with org and project info - * @throws {ContextError} If no project found - * @throws {ValidationError} If project exists in multiple organizations - * - * @internal Exported for testing - */ -export async function resolveFromProjectSearch( - projectSlug: string, - traceId: string -): Promise { - const found = await findProjectsBySlug(projectSlug); - if (found.length === 0) { - throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ - "Check that you have access to a project with this slug", - ]); - } - if (found.length > 1) { - const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); - throw new ValidationError( - `Project "${projectSlug}" exists in multiple organizations.\n\n` + - `Specify the organization:\n${orgList}\n\n` + - `Example: sentry trace view /${projectSlug} ${traceId}` - ); - } - // Safe assertion: length is exactly 1 after the checks above - const foundProject = found[0] as (typeof found)[0]; - return { - org: foundProject.orgSlug, - project: foundProject.slug, - }; -} - /** * Write human-readable trace output to stdout. * @@ -196,7 +159,11 @@ export const viewCommand = buildCommand({ break; case "project-search": - target = await resolveFromProjectSearch(parsed.projectSlug, traceId); + target = await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry trace view /${parsed.projectSlug} ${traceId}` + ); break; case "org-all": diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index b87d4877..310e9871 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -15,6 +15,7 @@ import { basename } from "node:path"; import { findProjectByDsnKey, findProjectsByPattern, + findProjectsBySlug, getProject, } from "./api-client.js"; import { getDefaultOrganization, getDefaultProject } from "./db/defaults.js"; @@ -33,7 +34,7 @@ import { formatMultipleProjectsFooter, getDsnSourceDescription, } from "./dsn/index.js"; -import { AuthError, ContextError } from "./errors.js"; +import { AuthError, ContextError, ValidationError } from "./errors.js"; /** * Resolved organization and project target for API calls. @@ -680,3 +681,45 @@ export async function resolveOrg( return null; } } + +/** + * Search for a project by slug across all accessible organizations. + * + * Common resolution step used by commands that accept a bare project slug + * (e.g., `sentry event view frontend `). Throws helpful errors when + * the project isn't found or exists in multiple orgs. + * + * @param projectSlug - Project slug to search for + * @param usageHint - Usage example shown in error messages + * @param disambiguationExample - Example command for multi-org disambiguation (e.g., "sentry event view /frontend abc123") + * @returns Resolved org and project slugs + * @throws {ContextError} If no project found + * @throws {ValidationError} If project exists in multiple organizations + */ +export async function resolveProjectBySlug( + projectSlug: string, + usageHint: string, + disambiguationExample?: string +): Promise<{ org: string; project: string }> { + const found = await findProjectsBySlug(projectSlug); + if (found.length === 0) { + throw new ContextError(`Project "${projectSlug}"`, usageHint, [ + "Check that you have access to a project with this slug", + ]); + } + if (found.length > 1) { + const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); + const example = disambiguationExample + ? `\n\nExample: ${disambiguationExample}` + : ""; + throw new ValidationError( + `Project "${projectSlug}" exists in multiple organizations.\n\n` + + `Specify the organization:\n${orgList}${example}` + ); + } + const foundProject = found[0] as (typeof found)[0]; + return { + org: foundProject.orgSlug, + project: foundProject.slug, + }; +} diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index 2ce6a362..fb894d75 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -6,14 +6,12 @@ */ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { - parsePositionalArgs, - resolveFromProjectSearch, -} from "../../../src/commands/event/view.js"; +import { parsePositionalArgs } from "../../../src/commands/event/view.js"; import type { ProjectWithOrg } from "../../../src/lib/api-client.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"; +import { resolveProjectBySlug } from "../../../src/lib/resolve-target.js"; describe("parsePositionalArgs", () => { describe("single argument (event ID only)", () => { @@ -93,7 +91,8 @@ describe("parsePositionalArgs", () => { }); }); -describe("resolveFromProjectSearch", () => { +describe("resolveProjectBySlug", () => { + const HINT = "sentry event view / "; let findProjectsBySlugSpy: ReturnType; beforeEach(() => { @@ -108,16 +107,16 @@ describe("resolveFromProjectSearch", () => { test("throws ContextError when project not found", async () => { findProjectsBySlugSpy.mockResolvedValue([]); - await expect( - resolveFromProjectSearch("my-project", "event-123") - ).rejects.toThrow(ContextError); + await expect(resolveProjectBySlug("my-project", HINT)).rejects.toThrow( + ContextError + ); }); test("includes project name in error message", async () => { findProjectsBySlugSpy.mockResolvedValue([]); try { - await resolveFromProjectSearch("frontend", "event-123"); + await resolveProjectBySlug("frontend", HINT); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -136,9 +135,9 @@ describe("resolveFromProjectSearch", () => { { slug: "frontend", orgSlug: "org-b", id: "2", name: "Frontend" }, ] as ProjectWithOrg[]); - await expect( - resolveFromProjectSearch("frontend", "event-123") - ).rejects.toThrow(ValidationError); + await expect(resolveProjectBySlug("frontend", HINT)).rejects.toThrow( + ValidationError + ); }); test("includes all orgs in error message", async () => { @@ -148,7 +147,11 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("frontend", "event-456"); + await resolveProjectBySlug( + "frontend", + HINT, + "sentry event view /frontend event-456" + ); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); @@ -156,7 +159,7 @@ describe("resolveFromProjectSearch", () => { expect(message).toContain("exists in multiple organizations"); expect(message).toContain("acme-corp/frontend"); expect(message).toContain("beta-inc/frontend"); - expect(message).toContain("event-456"); // Event ID in example + expect(message).toContain("event-456"); } }); @@ -168,7 +171,11 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("api", "abc123"); + await resolveProjectBySlug( + "api", + HINT, + "sentry event view /api abc123" + ); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); @@ -186,13 +193,11 @@ describe("resolveFromProjectSearch", () => { { slug: "backend", orgSlug: "my-company", id: "42", name: "Backend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("backend", "event-xyz"); + const result = await resolveProjectBySlug("backend", HINT); expect(result).toEqual({ org: "my-company", project: "backend", - orgDisplay: "my-company", - projectDisplay: "backend", }); }); @@ -206,10 +211,9 @@ describe("resolveFromProjectSearch", () => { }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("mobile-app", "evt-001"); + const result = await resolveProjectBySlug("mobile-app", HINT); expect(result.org).toBe("acme-industries"); - expect(result.orgDisplay).toBe("acme-industries"); }); test("preserves project slug in result", async () => { @@ -217,10 +221,9 @@ describe("resolveFromProjectSearch", () => { { slug: "web-frontend", orgSlug: "org", id: "1", name: "Web Frontend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("web-frontend", "e123"); + const result = await resolveProjectBySlug("web-frontend", HINT); expect(result.project).toBe("web-frontend"); - expect(result.projectDisplay).toBe("web-frontend"); }); }); }); diff --git a/test/commands/log/view.test.ts b/test/commands/log/view.test.ts index 92d4ac4d..db3b8c2f 100644 --- a/test/commands/log/view.test.ts +++ b/test/commands/log/view.test.ts @@ -6,14 +6,12 @@ */ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { - parsePositionalArgs, - resolveFromProjectSearch, -} from "../../../src/commands/log/view.js"; +import { parsePositionalArgs } from "../../../src/commands/log/view.js"; import type { ProjectWithOrg } from "../../../src/lib/api-client.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"; +import { resolveProjectBySlug } from "../../../src/lib/resolve-target.js"; describe("parsePositionalArgs", () => { describe("single argument (log ID only)", () => { @@ -91,7 +89,8 @@ describe("parsePositionalArgs", () => { }); }); -describe("resolveFromProjectSearch", () => { +describe("resolveProjectBySlug", () => { + const HINT = "sentry log view / "; let findProjectsBySlugSpy: ReturnType; beforeEach(() => { @@ -106,16 +105,16 @@ describe("resolveFromProjectSearch", () => { test("throws ContextError when project not found", async () => { findProjectsBySlugSpy.mockResolvedValue([]); - await expect( - resolveFromProjectSearch("my-project", "log-123") - ).rejects.toThrow(ContextError); + await expect(resolveProjectBySlug("my-project", HINT)).rejects.toThrow( + ContextError + ); }); test("includes project name in error message", async () => { findProjectsBySlugSpy.mockResolvedValue([]); try { - await resolveFromProjectSearch("frontend", "log-123"); + await resolveProjectBySlug("frontend", HINT); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -134,9 +133,9 @@ describe("resolveFromProjectSearch", () => { { slug: "frontend", orgSlug: "org-b", id: "2", name: "Frontend" }, ] as ProjectWithOrg[]); - await expect( - resolveFromProjectSearch("frontend", "log-123") - ).rejects.toThrow(ValidationError); + await expect(resolveProjectBySlug("frontend", HINT)).rejects.toThrow( + ValidationError + ); }); test("includes all orgs in error message", async () => { @@ -146,7 +145,11 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("frontend", "log-456"); + await resolveProjectBySlug( + "frontend", + HINT, + "sentry log view /frontend log-456" + ); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); @@ -154,7 +157,7 @@ describe("resolveFromProjectSearch", () => { expect(message).toContain("exists in multiple organizations"); expect(message).toContain("acme-corp/frontend"); expect(message).toContain("beta-inc/frontend"); - expect(message).toContain("log-456"); // Log ID in example + expect(message).toContain("log-456"); } }); @@ -166,7 +169,11 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("api", "abc123"); + await resolveProjectBySlug( + "api", + HINT, + "sentry log view /api abc123" + ); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); @@ -182,7 +189,7 @@ describe("resolveFromProjectSearch", () => { { slug: "backend", orgSlug: "my-company", id: "42", name: "Backend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("backend", "log-xyz"); + const result = await resolveProjectBySlug("backend", HINT); expect(result).toEqual({ org: "my-company", @@ -200,7 +207,7 @@ describe("resolveFromProjectSearch", () => { }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("mobile-app", "log-001"); + const result = await resolveProjectBySlug("mobile-app", HINT); expect(result.org).toBe("acme-industries"); }); @@ -210,7 +217,7 @@ describe("resolveFromProjectSearch", () => { { slug: "web-frontend", orgSlug: "org", id: "1", name: "Web Frontend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("web-frontend", "log123"); + const result = await resolveProjectBySlug("web-frontend", HINT); expect(result.project).toBe("web-frontend"); }); diff --git a/test/commands/trace/view.test.ts b/test/commands/trace/view.test.ts index 3bb7e43e..071b701c 100644 --- a/test/commands/trace/view.test.ts +++ b/test/commands/trace/view.test.ts @@ -6,14 +6,12 @@ */ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { - parsePositionalArgs, - resolveFromProjectSearch, -} from "../../../src/commands/trace/view.js"; +import { parsePositionalArgs } from "../../../src/commands/trace/view.js"; import type { ProjectWithOrg } from "../../../src/lib/api-client.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"; +import { resolveProjectBySlug } from "../../../src/lib/resolve-target.js"; describe("parsePositionalArgs", () => { describe("single argument (trace ID only)", () => { @@ -79,7 +77,8 @@ describe("parsePositionalArgs", () => { }); }); -describe("resolveFromProjectSearch", () => { +describe("resolveProjectBySlug", () => { + const HINT = "sentry trace view / "; let findProjectsBySlugSpy: ReturnType; beforeEach(() => { @@ -94,16 +93,16 @@ describe("resolveFromProjectSearch", () => { test("throws ContextError when project not found", async () => { findProjectsBySlugSpy.mockResolvedValue([]); - await expect( - resolveFromProjectSearch("my-project", "trace-123") - ).rejects.toThrow(ContextError); + await expect(resolveProjectBySlug("my-project", HINT)).rejects.toThrow( + ContextError + ); }); test("includes project name in error message", async () => { findProjectsBySlugSpy.mockResolvedValue([]); try { - await resolveFromProjectSearch("frontend", "trace-123"); + await resolveProjectBySlug("frontend", HINT); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -122,9 +121,9 @@ describe("resolveFromProjectSearch", () => { { slug: "frontend", orgSlug: "org-b", id: "2", name: "Frontend" }, ] as ProjectWithOrg[]); - await expect( - resolveFromProjectSearch("frontend", "trace-123") - ).rejects.toThrow(ValidationError); + await expect(resolveProjectBySlug("frontend", HINT)).rejects.toThrow( + ValidationError + ); }); test("includes all orgs and trace ID in error message", async () => { @@ -134,7 +133,11 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("frontend", "trace-456"); + await resolveProjectBySlug( + "frontend", + HINT, + "sentry trace view /frontend trace-456" + ); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); @@ -153,7 +156,7 @@ describe("resolveFromProjectSearch", () => { { slug: "backend", orgSlug: "my-company", id: "42", name: "Backend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("backend", "trace-xyz"); + const result = await resolveProjectBySlug("backend", HINT); expect(result).toEqual({ org: "my-company", @@ -171,7 +174,7 @@ describe("resolveFromProjectSearch", () => { }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("mobile-app", "trace-001"); + const result = await resolveProjectBySlug("mobile-app", HINT); expect(result.org).toBe("acme-industries"); expect(result.project).toBe("mobile-app"); From e81a85deda0ba85417aae2676e7ebd66c50baa2b Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 10 Feb 2026 21:26:14 +0100 Subject: [PATCH 5/7] test(project): add func tests for project view command --- test/commands/project/view.func.test.ts | 282 ++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 test/commands/project/view.func.test.ts diff --git a/test/commands/project/view.func.test.ts b/test/commands/project/view.func.test.ts new file mode 100644 index 00000000..4183706c --- /dev/null +++ b/test/commands/project/view.func.test.ts @@ -0,0 +1,282 @@ +/** + * Project View Command Func Tests + * + * Tests for the viewCommand func() body in src/commands/project/view.ts. + * Uses spyOn to mock api-client, resolve-target, and browser to test + * the func() body without real HTTP calls or database access. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { viewCommand } from "../../../src/commands/project/view.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"; +import { AuthError, ContextError } 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 { ProjectKey, SentryProject } from "../../../src/types/sentry.js"; + +const sampleProject: SentryProject = { + id: "42", + slug: "test-project", + name: "Test Project", + platform: "javascript", + dateCreated: "2025-01-01T00:00:00.000Z", + status: "active", +}; + +const sampleKeys: ProjectKey[] = [ + { + id: "key-1", + name: "Default", + dsn: { public: "https://abc123@o1.ingest.sentry.io/42" }, + isActive: true, + }, +]; + +function createMockContext() { + const stdoutWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", + setContext: mock(() => { + // no-op for test + }), + }, + stdoutWrite, + }; +} + +describe("viewCommand.func", () => { + let getProjectSpy: ReturnType; + let getProjectKeysSpy: ReturnType; + let resolveAllTargetsSpy: ReturnType; + let resolveProjectBySlugSpy: ReturnType; + let openInBrowserSpy: ReturnType; + + beforeEach(() => { + getProjectSpy = spyOn(apiClient, "getProject"); + getProjectKeysSpy = spyOn(apiClient, "getProjectKeys"); + resolveAllTargetsSpy = spyOn(resolveTarget, "resolveAllTargets"); + resolveProjectBySlugSpy = spyOn(resolveTarget, "resolveProjectBySlug"); + openInBrowserSpy = spyOn(browser, "openInBrowser"); + }); + + afterEach(() => { + getProjectSpy.mockRestore(); + getProjectKeysSpy.mockRestore(); + resolveAllTargetsSpy.mockRestore(); + resolveProjectBySlugSpy.mockRestore(); + openInBrowserSpy.mockRestore(); + }); + + test("explicit org/project outputs JSON with DSN", async () => { + getProjectSpy.mockResolvedValue(sampleProject); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { json: true, web: false }, "my-org/test-project"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.slug).toBe("test-project"); + expect(parsed.dsn).toBe("https://abc123@o1.ingest.sentry.io/42"); + }); + + test("explicit org/project outputs human-readable details", async () => { + getProjectSpy.mockResolvedValue(sampleProject); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { json: false, web: false }, + "my-org/test-project" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("test-project"); + expect(output).toContain("Slug:"); + }); + + test("explicit org/project with --web opens browser", async () => { + openInBrowserSpy.mockResolvedValue(undefined); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { json: false, web: true }, "my-org/test-project"); + + expect(openInBrowserSpy).toHaveBeenCalled(); + // Should NOT fetch project details when using --web + expect(getProjectSpy).not.toHaveBeenCalled(); + }); + + test("--web with multiple auto-detected targets throws ContextError", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { + org: "org-a", + project: "proj-1", + orgDisplay: "org-a", + projectDisplay: "proj-1", + }, + { + org: "org-b", + project: "proj-2", + orgDisplay: "org-b", + projectDisplay: "proj-2", + }, + ], + }); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + try { + // No target arg triggers AutoDetect + await func.call(context, { json: false, web: true }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain("Single project"); + } + }); + + test("project search resolves and fetches project", async () => { + resolveProjectBySlugSpy.mockResolvedValue({ + org: "acme", + project: "frontend", + }); + getProjectSpy.mockResolvedValue({ ...sampleProject, slug: "frontend" }); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { json: true, web: false }, "frontend"); + + expect(resolveProjectBySlugSpy).toHaveBeenCalledWith( + "frontend", + "sentry project view /", + "sentry project view /frontend" + ); + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.slug).toBe("frontend"); + }); + + test("org-only target (org/) throws ContextError", async () => { + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + try { + await func.call(context, { json: false, web: false }, "my-org/"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain("Specific project"); + expect((error as ContextError).message).toContain( + "not just the organization" + ); + } + }); + + test("auto-detect uses resolveAllTargets and writes footer", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { + org: "my-org", + project: "backend", + orgDisplay: "my-org", + projectDisplay: "backend", + detectedFrom: ".env", + }, + ], + footer: "Detected 1 project from .env", + }); + getProjectSpy.mockResolvedValue({ ...sampleProject, slug: "backend" }); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + // No target arg triggers AutoDetect + await func.call(context, { json: false, web: false }); + + expect(resolveAllTargetsSpy).toHaveBeenCalled(); + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("backend"); + expect(output).toContain("Detected 1 project from .env"); + }); + + test("auto-detect with 0 targets throws ContextError", async () => { + resolveAllTargetsSpy.mockResolvedValue({ targets: [] }); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + await expect( + func.call(context, { json: false, web: false }) + ).rejects.toThrow(ContextError); + }); + + test("auto-detect with skippedSelfHosted includes DSN hint in error", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [], + skippedSelfHosted: 3, + }); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + try { + await func.call(context, { json: false, web: false }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + const msg = (error as ContextError).message; + expect(msg).toContain("3 DSN(s)"); + expect(msg).toContain("could not be resolved"); + } + }); + + test("non-auth API error is skipped silently", async () => { + getProjectSpy.mockRejectedValue(new Error("404 Not Found")); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + // The project fetch fails with a non-auth error, so it's filtered out. + // With no successful results, buildContextError is thrown. + await expect( + func.call(context, { json: false, web: false }, "my-org/bad-project") + ).rejects.toThrow(ContextError); + + // getProject was called (it just failed) + expect(getProjectSpy).toHaveBeenCalledWith("my-org", "bad-project"); + }); + + test("auth error from API is rethrown", async () => { + getProjectSpy.mockRejectedValue(new AuthError("not_authenticated")); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + await expect( + func.call(context, { json: false, web: false }, "my-org/test-project") + ).rejects.toThrow(AuthError); + }); +}); From 94f22ff2c74b78f52fe8e44a0594e6c1bdf7a8a1 Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 11 Feb 2026 18:05:54 +0100 Subject: [PATCH 6/7] feat(formatters): add Seer fixability score to issue list and detail views Surfaces seerFixabilityScore from the Sentry API on issue list and detail views. Shows a 3-tier label (high/med/low) with the raw percentage, e.g. med(50%) in lists, Med (50%) in detail. Color-coded green/yellow/red. Mirrors the approach in getsentry/sentry-mcp#732. --- src/lib/formatters/colors.ts | 21 +++++++++ src/lib/formatters/human.ts | 91 ++++++++++++++++++++++++++++++++++-- src/types/sentry.ts | 5 ++ test/fixtures/issue.json | 3 +- test/fixtures/issues.json | 6 ++- 5 files changed, 119 insertions(+), 7 deletions(-) diff --git a/src/lib/formatters/colors.ts b/src/lib/formatters/colors.ts index aacc847e..e7f841d4 100644 --- a/src/lib/formatters/colors.ts +++ b/src/lib/formatters/colors.ts @@ -88,3 +88,24 @@ export function levelColor(text: string, level: string | undefined): string { const colorFn = LEVEL_COLORS[normalizedLevel]; return colorFn ? colorFn(text) : text; } + +// Fixability-based Coloring + +/** Fixability tier labels returned by getSeerFixabilityLabel() */ +export type FixabilityTier = "high" | "med" | "low"; + +const FIXABILITY_COLORS: Record string> = { + high: green, + med: yellow, + low: red, +}; + +/** + * Color text based on Seer fixability tier. + * + * @param text - Text to colorize + * @param tier - Fixability tier label (`"high"`, `"med"`, or `"low"`) + */ +export function fixabilityColor(text: string, tier: FixabilityTier): string { + return FIXABILITY_COLORS[tier](text); +} diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index f7b5abe8..7850b55e 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -23,6 +23,8 @@ import type { import { withSerializeSpan } from "../telemetry.js"; import { boldUnderline, + type FixabilityTier, + fixabilityColor, green, levelColor, muted, @@ -55,6 +57,60 @@ function capitalize(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } +/** + * Convert Seer fixability score to a tier label. + * + * Thresholds are simplified from Sentry core (sentry/seer/autofix/constants.py) + * into 3 tiers for CLI display. + * + * @param score - Numeric fixability score (0-1) + * @returns `"high"` | `"med"` | `"low"` + */ +export function getSeerFixabilityLabel(score: number): FixabilityTier { + if (score > 0.66) { + return "high"; + } + if (score > 0.33) { + return "med"; + } + return "low"; +} + +/** + * Format fixability score as "label(pct%)" for compact list display. + * + * @param score - Numeric fixability score, or null/undefined if unavailable + * @returns Formatted string like `"med(50%)"`, or `""` when score is unavailable + */ +export function formatFixability(score: number | null | undefined): string { + if (score === null || score === undefined) { + return ""; + } + const label = getSeerFixabilityLabel(score); + const pct = Math.round(score * 100); + return `${label}(${pct}%)`; +} + +/** + * Format fixability score for detail view: "Label (pct%)". + * + * Uses capitalized label with space before parens for readability + * in the single-issue detail display. + * + * @param score - Numeric fixability score, or null/undefined if unavailable + * @returns Formatted string like `"Med (50%)"`, or `""` when score is unavailable + */ +export function formatFixabilityDetail( + score: number | null | undefined +): string { + if (score === null || score === undefined) { + return ""; + } + const label = getSeerFixabilityLabel(score); + const pct = Math.round(score * 100); + return `${capitalize(label)} (${pct}%)`; +} + /** Map of entry type strings to their TypeScript types */ type EntryTypeMap = { exception: ExceptionEntry; @@ -254,10 +310,12 @@ const COL_ALIAS = 15; const COL_SHORT_ID = 22; const COL_COUNT = 5; const COL_SEEN = 10; +/** Width for the FIXABILITY column (longest value "high(100%)" = 10) */ +const COL_FIX = 10; /** Column where title starts in single-project mode (no ALIAS column) */ const TITLE_START_COL = - COL_LEVEL + 1 + COL_SHORT_ID + 1 + COL_COUNT + 2 + COL_SEEN + 2; // = 50 + COL_LEVEL + 1 + COL_SHORT_ID + 1 + COL_COUNT + 2 + COL_SEEN + 2 + COL_FIX + 2; /** Column where title starts in multi-project mode (with ALIAS column) */ const TITLE_START_COL_MULTI = @@ -270,7 +328,9 @@ const TITLE_START_COL_MULTI = COL_COUNT + 2 + COL_SEEN + - 2; // = 66 + 2 + + COL_FIX + + 2; /** * Format the header row for issue list table. @@ -291,6 +351,8 @@ export function formatIssueListHeader(isMultiProject = false): string { " " + "SEEN".padEnd(COL_SEEN) + " " + + "FIXABILITY".padEnd(COL_FIX) + + " " + "TITLE" ); } @@ -303,6 +365,8 @@ export function formatIssueListHeader(isMultiProject = false): string { " " + "SEEN".padEnd(COL_SEEN) + " " + + "FIXABILITY".padEnd(COL_FIX) + + " " + "TITLE" ); } @@ -521,6 +585,15 @@ export function formatIssueRow( const count = `${issue.count}`.padStart(COL_COUNT); const seen = formatRelativeTime(issue.lastSeen); + // Fixability column (color applied after padding to preserve alignment) + const fixText = formatFixability(issue.seerFixabilityScore); + const fixPadding = " ".repeat(Math.max(0, COL_FIX - fixText.length)); + const score = issue.seerFixabilityScore; + const fix = + fixText && score !== null && score !== undefined + ? fixabilityColor(fixText, getSeerFixabilityLabel(score)) + fixPadding + : fixPadding; + // Multi-project mode: include ALIAS column if (isMultiProject) { const aliasShorthand = computeAliasShorthand(issue.shortId, projectAlias); @@ -529,11 +602,11 @@ export function formatIssueRow( ); const alias = `${aliasShorthand}${aliasPadding}`; const title = wrapTitle(issue.title, TITLE_START_COL_MULTI, termWidth); - return `${level} ${alias} ${shortId} ${count} ${seen} ${title}`; + return `${level} ${alias} ${shortId} ${count} ${seen} ${fix} ${title}`; } const title = wrapTitle(issue.title, TITLE_START_COL, termWidth); - return `${level} ${shortId} ${count} ${seen} ${title}`; + return `${level} ${shortId} ${count} ${seen} ${fix} ${title}`; } /** @@ -564,6 +637,16 @@ export function formatIssueDetails(issue: SentryIssue): string[] { lines.push(`Priority: ${capitalize(issue.priority)}`); } + // Seer fixability + if ( + issue.seerFixabilityScore !== null && + issue.seerFixabilityScore !== undefined + ) { + const fixDetail = formatFixabilityDetail(issue.seerFixabilityScore); + const tier = getSeerFixabilityLabel(issue.seerFixabilityScore); + lines.push(`Fixability: ${fixabilityColor(fixDetail, tier)}`); + } + // Level with unhandled indicator let levelLine = issue.level ?? "unknown"; if (issue.isUnhandled) { diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 7d924f1b..28b7be60 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -258,6 +258,11 @@ export const SentryIssueSchema = z // Release information firstRelease: ReleaseSchema.nullable().optional(), lastRelease: ReleaseSchema.nullable().optional(), + /** + * Seer AI fixability score (0-1). Higher = easier to fix automatically. + * `null` when Seer has not analyzed this issue; absent when the org has Seer disabled. + */ + seerFixabilityScore: z.number().nullable().optional(), }) .passthrough(); diff --git a/test/fixtures/issue.json b/test/fixtures/issue.json index aaf73dd0..86b5a0bb 100644 --- a/test/fixtures/issue.json +++ b/test/fixtures/issue.json @@ -45,5 +45,6 @@ "pluginIssues": [], "pluginContexts": [], "userReportCount": 0, - "participants": [] + "participants": [], + "seerFixabilityScore": 0.7 } diff --git a/test/fixtures/issues.json b/test/fixtures/issues.json index 77910fc3..80d8e40f 100644 --- a/test/fixtures/issues.json +++ b/test/fixtures/issues.json @@ -37,7 +37,8 @@ "count": "142", "userCount": 28, "firstSeen": "2024-01-10T14:30:00.000000Z", - "lastSeen": "2024-01-15T09:45:00.000000Z" + "lastSeen": "2024-01-15T09:45:00.000000Z", + "seerFixabilityScore": 0.7 }, { "id": "400002", @@ -77,6 +78,7 @@ "count": "53", "userCount": 12, "firstSeen": "2024-01-12T08:15:00.000000Z", - "lastSeen": "2024-01-14T16:20:00.000000Z" + "lastSeen": "2024-01-14T16:20:00.000000Z", + "seerFixabilityScore": 0.3 } ] From 7161765caa51cc5349e8fa35d55680fbf18f5209 Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 11 Feb 2026 18:06:00 +0100 Subject: [PATCH 7/7] test(formatters): add fixability unit and property-based tests Unit tests for all fixability functions with boundary/edge cases (score=0, score=1, null, undefined). Property-based tests for tier validity, monotonicity, format pattern matching, and column width constraints. --- test/lib/formatters/human.details.test.ts | 111 +++++++++++++++++++++ test/lib/formatters/human.property.test.ts | 110 +++++++++++++++++++- 2 files changed, 220 insertions(+), 1 deletion(-) diff --git a/test/lib/formatters/human.details.test.ts b/test/lib/formatters/human.details.test.ts index 4214b176..b5a752a3 100644 --- a/test/lib/formatters/human.details.test.ts +++ b/test/lib/formatters/human.details.test.ts @@ -9,11 +9,14 @@ import { describe, expect, test } from "bun:test"; import { calculateOrgSlugWidth, calculateProjectColumnWidths, + formatFixability, + formatFixabilityDetail, formatIssueDetails, formatOrgDetails, formatOrgRow, formatProjectDetails, formatProjectRow, + getSeerFixabilityLabel, } from "../../../src/lib/formatters/human.js"; import type { SentryIssue, @@ -512,4 +515,112 @@ describe("formatIssueDetails", () => { expect(lines.some((l) => l.includes("Platform: unknown"))).toBe(true); expect(lines.some((l) => l.includes("Type: unknown"))).toBe(true); }); + + test("includes fixability with percentage when seerFixabilityScore is present", () => { + const issue = createMockIssue({ seerFixabilityScore: 0.7 }); + const lines = formatIssueDetails(issue).map(stripAnsi); + + expect(lines.some((l) => l.includes("Fixability: High (70%)"))).toBe(true); + }); + + test("omits fixability when seerFixabilityScore is null", () => { + const issue = createMockIssue({ seerFixabilityScore: null }); + const lines = formatIssueDetails(issue).map(stripAnsi); + + expect(lines.some((l) => l.includes("Fixability:"))).toBe(false); + }); + + test("omits fixability when seerFixabilityScore is undefined", () => { + const issue = createMockIssue({ seerFixabilityScore: undefined }); + const lines = formatIssueDetails(issue).map(stripAnsi); + + expect(lines.some((l) => l.includes("Fixability:"))).toBe(false); + }); + + test("shows med label for medium score", () => { + const issue = createMockIssue({ seerFixabilityScore: 0.5 }); + const lines = formatIssueDetails(issue).map(stripAnsi); + + expect(lines.some((l) => l.includes("Fixability: Med (50%)"))).toBe(true); + }); + + test("shows low label for low score", () => { + const issue = createMockIssue({ seerFixabilityScore: 0.1 }); + const lines = formatIssueDetails(issue).map(stripAnsi); + + expect(lines.some((l) => l.includes("Fixability: Low (10%)"))).toBe(true); + }); +}); + +// Seer Fixability Tests + +describe("getSeerFixabilityLabel", () => { + test("returns high for scores above 0.66", () => { + expect(getSeerFixabilityLabel(0.67)).toBe("high"); + expect(getSeerFixabilityLabel(0.99)).toBe("high"); + expect(getSeerFixabilityLabel(0.8)).toBe("high"); + }); + + test("returns med for scores between 0.33 and 0.66", () => { + expect(getSeerFixabilityLabel(0.66)).toBe("med"); + expect(getSeerFixabilityLabel(0.5)).toBe("med"); + expect(getSeerFixabilityLabel(0.34)).toBe("med"); + }); + + test("returns low for scores at or below 0.33", () => { + expect(getSeerFixabilityLabel(0.33)).toBe("low"); + expect(getSeerFixabilityLabel(0.1)).toBe("low"); + expect(getSeerFixabilityLabel(0)).toBe("low"); + }); + + test("handles extreme boundary values", () => { + expect(getSeerFixabilityLabel(1)).toBe("high"); + expect(getSeerFixabilityLabel(0)).toBe("low"); + }); +}); + +describe("formatFixability", () => { + test("formats score as label(pct%)", () => { + expect(formatFixability(0.5)).toBe("med(50%)"); + expect(formatFixability(0.8)).toBe("high(80%)"); + expect(formatFixability(0.2)).toBe("low(20%)"); + }); + + test("rounds percentage to nearest integer", () => { + expect(formatFixability(0.495)).toBe("med(50%)"); + expect(formatFixability(0.333)).toBe("med(33%)"); + }); + + test("handles boundary values", () => { + expect(formatFixability(0)).toBe("low(0%)"); + expect(formatFixability(1)).toBe("high(100%)"); + }); + + test("max output fits within column width", () => { + // "high(100%)" = 10 chars, matching COL_FIX + expect(formatFixability(1).length).toBeLessThanOrEqual(10); + }); + + test("returns empty string for null or undefined", () => { + expect(formatFixability(null)).toBe(""); + expect(formatFixability(undefined)).toBe(""); + }); +}); + +describe("formatFixabilityDetail", () => { + test("formats with capitalized label and space", () => { + expect(formatFixabilityDetail(0.5)).toBe("Med (50%)"); + expect(formatFixabilityDetail(0.8)).toBe("High (80%)"); + expect(formatFixabilityDetail(0.2)).toBe("Low (20%)"); + }); + + test("handles boundary values", () => { + expect(formatFixabilityDetail(0)).toBe("Low (0%)"); + expect(formatFixabilityDetail(1)).toBe("High (100%)"); + }); + + test("returns empty string for null or undefined", () => { + expect(formatFixabilityDetail(null)).toBe(""); + expect(formatFixabilityDetail(undefined)).toBe(""); + }); }); diff --git a/test/lib/formatters/human.property.test.ts b/test/lib/formatters/human.property.test.ts index f2d7630f..bf69810e 100644 --- a/test/lib/formatters/human.property.test.ts +++ b/test/lib/formatters/human.property.test.ts @@ -7,15 +7,21 @@ import { describe, expect, test } from "bun:test"; import { + constant, + double, assert as fcAssert, + oneof, property, stringMatching, tuple, } from "fast-check"; import { + formatFixability, + formatFixabilityDetail, formatIssueListHeader, formatShortId, formatUserIdentity, + getSeerFixabilityLabel, } from "../../../src/lib/formatters/human.js"; import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; @@ -246,7 +252,14 @@ describe("formatIssueListHeader properties", () => { }); test("both modes include essential columns", async () => { - const essentialColumns = ["LEVEL", "SHORT ID", "COUNT", "SEEN", "TITLE"]; + const essentialColumns = [ + "LEVEL", + "SHORT ID", + "COUNT", + "SEEN", + "FIXABILITY", + "TITLE", + ]; for (const isMultiProject of [true, false]) { const header = formatIssueListHeader(isMultiProject); @@ -256,3 +269,98 @@ describe("formatIssueListHeader properties", () => { } }); }); + +// Fixability Formatting Properties + +/** Score in valid API range [0, 1] */ +const scoreArb = double({ min: 0, max: 1, noNaN: true }); + +/** Score or null/undefined (full input space for formatFixability) */ +const nullableScoreArb = oneof( + scoreArb, + constant(null as null), + constant(undefined as undefined) +); + +describe("property: getSeerFixabilityLabel", () => { + test("always returns one of the three valid tiers", () => { + fcAssert( + property(scoreArb, (score) => { + const label = getSeerFixabilityLabel(score); + expect(["high", "med", "low"]).toContain(label); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("is monotonic: higher scores never produce lower tiers", () => { + const tierRank = { low: 0, med: 1, high: 2 }; + fcAssert( + property(scoreArb, scoreArb, (a, b) => { + if (a <= b) { + const rankA = + tierRank[getSeerFixabilityLabel(a) as keyof typeof tierRank]; + const rankB = + tierRank[getSeerFixabilityLabel(b) as keyof typeof tierRank]; + expect(rankA).toBeLessThanOrEqual(rankB); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: formatFixability", () => { + test("matches expected pattern for valid scores", () => { + fcAssert( + property(scoreArb, (score) => { + const result = formatFixability(score); + expect(result).toMatch(/^(high|med|low)\(\d+%\)$/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output never exceeds column width (10 chars)", () => { + fcAssert( + property(scoreArb, (score) => { + expect(formatFixability(score).length).toBeLessThanOrEqual(10); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns empty string for null or undefined", () => { + fcAssert( + property(nullableScoreArb, (score) => { + if (score === null || score === undefined) { + expect(formatFixability(score)).toBe(""); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: formatFixabilityDetail", () => { + test("matches expected pattern for valid scores", () => { + fcAssert( + property(scoreArb, (score) => { + const result = formatFixabilityDetail(score); + expect(result).toMatch(/^(High|Med|Low) \(\d+%\)$/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns empty string for null or undefined", () => { + fcAssert( + property(nullableScoreArb, (score) => { + if (score === null || score === undefined) { + expect(formatFixabilityDetail(score)).toBe(""); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +});