diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 26a9df87f..0189cccea 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -835,7 +835,10 @@ async function createSentryProject( if (options.org && options.project) { const existing = await tryGetExistingProject(orgSlug, slug); if (existing) { - return existing; + return { + ...existing, + message: `Using existing project "${slug}" in ${orgSlug}`, + }; } } diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 675cc8150..c8ff46e01 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -30,8 +30,6 @@ export type LocalOpPayload = export type ListDirPayload = { type: "local-op"; operation: "list-dir"; - /** Human-readable spinner hint from the server (≤ 120 chars, sensitive values redacted). */ - detail?: string; cwd: string; params: { path: string; @@ -44,8 +42,6 @@ export type ListDirPayload = { export type ReadFilesPayload = { type: "local-op"; operation: "read-files"; - /** Human-readable spinner hint from the server (≤ 120 chars, sensitive values redacted). */ - detail?: string; cwd: string; params: { paths: string[]; @@ -56,8 +52,6 @@ export type ReadFilesPayload = { export type FileExistsBatchPayload = { type: "local-op"; operation: "file-exists-batch"; - /** Human-readable spinner hint from the server (≤ 120 chars, sensitive values redacted). */ - detail?: string; cwd: string; params: { paths: string[]; @@ -67,8 +61,6 @@ export type FileExistsBatchPayload = { export type RunCommandsPayload = { type: "local-op"; operation: "run-commands"; - /** Human-readable spinner hint from the server (≤ 120 chars, sensitive values redacted). */ - detail?: string; cwd: string; params: { commands: string[]; @@ -79,8 +71,6 @@ export type RunCommandsPayload = { export type ApplyPatchsetPayload = { type: "local-op"; operation: "apply-patchset"; - /** Human-readable spinner hint from the server (≤ 120 chars, sensitive values redacted). */ - detail?: string; cwd: string; params: { patches: Array<{ @@ -94,8 +84,6 @@ export type ApplyPatchsetPayload = { export type CreateSentryProjectPayload = { type: "local-op"; operation: "create-sentry-project"; - /** Human-readable spinner hint from the server (≤ 120 chars, sensitive values redacted). */ - detail?: string; cwd: string; params: { name: string; @@ -106,6 +94,8 @@ export type CreateSentryProjectPayload = { export type LocalOpResult = { ok: boolean; error?: string; + /** Optional user-facing message (e.g. "Using existing project 'foo'"). */ + message?: string; data?: unknown; }; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 1b05a786f..92eedf310 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -7,6 +7,7 @@ */ import { randomBytes } from "node:crypto"; +import { basename } from "node:path"; import { cancel, confirm, @@ -49,6 +50,7 @@ import { tryGetExistingProject, } from "./local-ops.js"; import type { + LocalOpPayload, LocalOpResult, SuspendPayload, WizardOptions, @@ -90,6 +92,91 @@ function truncateForTerminal(message: string): string { return `${message.slice(0, maxWidth - 1)}…`; } +/** + * Build a human-readable spinner message from the payload params. + * Each operation type generates a descriptive message showing which + * files are being read, written, or checked. + */ +export function describeLocalOp(payload: LocalOpPayload): string { + switch (payload.operation) { + case "read-files": { + const paths = payload.params.paths; + return describeFilePaths("Reading", paths); + } + case "file-exists-batch": { + const paths = payload.params.paths; + return describeFilePaths("Checking", paths); + } + case "apply-patchset": { + const patches = payload.params.patches; + const first = patches[0]; + if (patches.length === 1 && first) { + const verb = patchActionVerb(first.action); + return `${verb} ${basename(first.path)}...`; + } + const counts = patchActionCounts(patches); + return `Applying ${patches.length} file changes (${counts})...`; + } + case "run-commands": { + const cmds = payload.params.commands; + const first = cmds[0]; + if (cmds.length === 1 && first) { + return `Running ${first}...`; + } + return `Running ${cmds.length} commands (${first ?? "..."}, ...)...`; + } + case "list-dir": + return "Listing directory..."; + case "create-sentry-project": + return `Creating project "${payload.params.name}" (${payload.params.platform})...`; + default: + return `${(payload as { operation: string }).operation}...`; + } +} + +/** Format a file paths list into a human-readable message with a verb prefix. */ +function describeFilePaths(verb: string, paths: string[]): string { + const first = paths[0]; + const second = paths[1]; + if (!first) { + return `${verb} files...`; + } + if (paths.length === 1) { + return `${verb} ${basename(first)}...`; + } + if (paths.length === 2 && second) { + return `${verb} ${basename(first)}, ${basename(second)}...`; + } + return `${verb} ${paths.length} files (${basename(first)}${second ? `, ${basename(second)}` : ""}, ...)...`; +} + +/** Map a patch action to a user-facing verb. */ +function patchActionVerb(action: string): string { + switch (action) { + case "create": + return "Creating"; + case "modify": + return "Modifying"; + case "delete": + return "Deleting"; + default: + return "Updating"; + } +} + +/** Summarize patch actions into a compact string like "2 created, 1 modified". */ +function patchActionCounts(patches: Array<{ action: string }>): string { + const counts = new Map(); + for (const p of patches) { + counts.set(p.action, (counts.get(p.action) ?? 0) + 1); + } + const parts: string[] = []; + for (const [action, count] of counts) { + parts.push(`${count} ${action === "modify" ? "modified" : `${action}d`}`); + } + return parts.join(", "); +} + async function handleSuspendedStep( ctx: StepContext, stepPhases: Map, @@ -99,13 +186,16 @@ async function handleSuspendedStep( const label = STEP_LABELS[stepId] ?? stepId; if (payload.type === "local-op") { - const message = payload.detail - ? payload.detail - : `${label} (${payload.operation})...`; + const message = describeLocalOp(payload); spin.message(truncateForTerminal(message)); const localResult = await handleLocalOp(payload, options); + if (localResult.message) { + spin.stop(localResult.message); + spin.start("Processing..."); + } + const history = stepHistory.get(stepId) ?? []; history.push(localResult); stepHistory.set(stepId, history); diff --git a/test/lib/init/local-ops.create-sentry-project.test.ts b/test/lib/init/local-ops.create-sentry-project.test.ts index 3cb8bf1be..a67035709 100644 --- a/test/lib/init/local-ops.create-sentry-project.test.ts +++ b/test/lib/init/local-ops.create-sentry-project.test.ts @@ -433,6 +433,9 @@ describe("create-sentry-project", () => { ); expect(result.ok).toBe(true); + expect(result.message).toBe( + 'Using existing project "my-app" in acme-corp' + ); const data = result.data as { orgSlug: string; projectSlug: string }; expect(data.orgSlug).toBe("acme-corp"); expect(data.projectSlug).toBe("my-app"); diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index ffd8701d9..02f43d599 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -36,10 +36,14 @@ import * as inter from "../../../src/lib/init/interactive.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as ops from "../../../src/lib/init/local-ops.js"; import type { + LocalOpPayload, WizardOptions, WorkflowRunResult, } from "../../../src/lib/init/types.js"; -import { runWizard } from "../../../src/lib/init/wizard-runner.js"; +import { + describeLocalOp, + runWizard, +} from "../../../src/lib/init/wizard-runner.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as sentryUrls from "../../../src/lib/sentry-urls.js"; @@ -538,7 +542,7 @@ describe("runWizard", () => { expect(payload.operation).toBe("list-dir"); }); - test("uses detail field for spinner message when present", async () => { + test("generates spinner message from payload params", async () => { mockStartResult = { status: "suspended", suspended: [["install-deps"]], @@ -547,10 +551,9 @@ describe("runWizard", () => { suspendPayload: { type: "local-op", operation: "run-commands", - detail: "npm install @sentry/nextjs @sentry/profiling-node", cwd: "/app", params: { - commands: ["npm install @sentry/nextjs @sentry/profiling-node"], + commands: ["pip install sentry-sdk"], }, }, }, @@ -561,11 +564,11 @@ describe("runWizard", () => { await runWizard(makeOptions()); expect(spinnerMock.message).toHaveBeenCalledWith( - "npm install @sentry/nextjs @sentry/profiling-node" + "Running pip install sentry-sdk..." ); }); - test("falls back to generic message when detail is absent", async () => { + test("generates message for run-commands operation", async () => { mockStartResult = { status: "suspended", suspended: [["install-deps"]], @@ -585,19 +588,17 @@ describe("runWizard", () => { await runWizard(makeOptions()); expect(spinnerMock.message).toHaveBeenCalledWith( - "Installing dependencies (run-commands)..." + "Running npm install @sentry/nextjs..." ); }); - test("truncates detail message when terminal is narrow", async () => { + test("truncates generated message when terminal is narrow", async () => { const origColumns = process.stdout.columns; Object.defineProperty(process.stdout, "columns", { value: 40, configurable: true, }); - const longDetail = - "npm install @sentry/nextjs @sentry/profiling-node @sentry/browser"; mockStartResult = { status: "suspended", suspended: [["install-deps"]], @@ -606,9 +607,12 @@ describe("runWizard", () => { suspendPayload: { type: "local-op", operation: "run-commands", - detail: longDetail, cwd: "/app", - params: { commands: [longDetail] }, + params: { + commands: [ + "npm install @sentry/nextjs @sentry/profiling-node @sentry/browser", + ], + }, }, }, }, @@ -620,7 +624,7 @@ describe("runWizard", () => { // 40 columns - 4 reserved = 36 max, truncated with "…" const call = spinnerMock.message.mock.calls.find((c: string[]) => - c[0]?.includes("npm install") + c[0]?.includes("Running") ) as string[] | undefined; expect(call).toBeDefined(); const msg = call?.[0] ?? ""; @@ -634,6 +638,41 @@ describe("runWizard", () => { } }); + test("displays message from LocalOpResult via spin.stop", async () => { + handleLocalOpSpy.mockResolvedValue({ + ok: true, + message: 'Using existing project "my-app" in acme', + data: { orgSlug: "acme", projectSlug: "my-app" }, + }); + + mockStartResult = { + status: "suspended", + suspended: [["ensure-sentry-project"]], + steps: { + "ensure-sentry-project": { + suspendPayload: { + type: "local-op", + operation: "create-sentry-project", + cwd: "/app", + params: { name: "my-app", platform: "python" }, + }, + }, + }, + }; + mockResumeResults = [{ status: "success" }]; + + await runWizard(makeOptions()); + + expect(spinnerMock.stop).toHaveBeenCalledWith( + 'Using existing project "my-app" in acme' + ); + // Spinner should restart after showing the message + const startCalls = spinnerMock.start.mock.calls.map( + (c: string[]) => c[0] + ); + expect(startCalls).toContain("Processing..."); + }); + test("dispatches interactive payload to handleInteractive", async () => { mockStartResult = { status: "suspended", @@ -887,3 +926,176 @@ describe("runWizard", () => { }); }); }); + +// ── describeLocalOp unit tests ────────────────────────────────────────────── + +describe("describeLocalOp", () => { + function payload( + overrides: Partial & + Pick + ): LocalOpPayload { + return { type: "local-op", cwd: "/app", ...overrides } as LocalOpPayload; + } + + describe("read-files", () => { + test("single file shows basename", () => { + const msg = describeLocalOp( + payload({ + operation: "read-files", + params: { paths: ["src/settings.py"] }, + }) + ); + expect(msg).toBe("Reading settings.py..."); + }); + + test("two files shows both basenames", () => { + const msg = describeLocalOp( + payload({ + operation: "read-files", + params: { paths: ["src/settings.py", "src/urls.py"] }, + }) + ); + expect(msg).toBe("Reading settings.py, urls.py..."); + }); + + test("three+ files shows count and first two basenames", () => { + const msg = describeLocalOp( + payload({ + operation: "read-files", + params: { + paths: ["a/one.py", "b/two.py", "c/three.py", "d/four.py"], + }, + }) + ); + expect(msg).toBe("Reading 4 files (one.py, two.py, ...)..."); + }); + + test("empty paths array", () => { + const msg = describeLocalOp( + payload({ operation: "read-files", params: { paths: [] } }) + ); + expect(msg).toBe("Reading files..."); + }); + }); + + describe("file-exists-batch", () => { + test("single file shows basename", () => { + const msg = describeLocalOp( + payload({ + operation: "file-exists-batch", + params: { paths: ["requirements.txt"] }, + }) + ); + expect(msg).toBe("Checking requirements.txt..."); + }); + + test("multiple files shows count", () => { + const msg = describeLocalOp( + payload({ + operation: "file-exists-batch", + params: { paths: ["a.py", "b.py", "c.py"] }, + }) + ); + expect(msg).toBe("Checking 3 files (a.py, b.py, ...)..."); + }); + }); + + describe("apply-patchset", () => { + test("single create shows verb and basename", () => { + const msg = describeLocalOp( + payload({ + operation: "apply-patchset", + params: { + patches: [{ path: "src/sentry.py", action: "create", patch: "" }], + }, + }) + ); + expect(msg).toBe("Creating sentry.py..."); + }); + + test("single modify shows verb and basename", () => { + const msg = describeLocalOp( + payload({ + operation: "apply-patchset", + params: { + patches: [{ path: "settings.py", action: "modify", patch: "" }], + }, + }) + ); + expect(msg).toBe("Modifying settings.py..."); + }); + + test("single delete shows verb and basename", () => { + const msg = describeLocalOp( + payload({ + operation: "apply-patchset", + params: { + patches: [{ path: "old.js", action: "delete", patch: "" }], + }, + }) + ); + expect(msg).toBe("Deleting old.js..."); + }); + + test("multiple patches shows count and breakdown", () => { + const msg = describeLocalOp( + payload({ + operation: "apply-patchset", + params: { + patches: [ + { path: "a.py", action: "create", patch: "" }, + { path: "b.py", action: "create", patch: "" }, + { path: "c.py", action: "modify", patch: "" }, + ], + }, + }) + ); + expect(msg).toBe("Applying 3 file changes (2 created, 1 modified)..."); + }); + }); + + describe("run-commands", () => { + test("single command shows the command", () => { + const msg = describeLocalOp( + payload({ + operation: "run-commands", + params: { commands: ["pip install sentry-sdk"] }, + }) + ); + expect(msg).toBe("Running pip install sentry-sdk..."); + }); + + test("multiple commands shows count and first", () => { + const msg = describeLocalOp( + payload({ + operation: "run-commands", + params: { + commands: ["pip install sentry-sdk", "python manage.py check"], + }, + }) + ); + expect(msg).toBe("Running 2 commands (pip install sentry-sdk, ...)..."); + }); + }); + + describe("list-dir", () => { + test("shows generic listing message", () => { + const msg = describeLocalOp( + payload({ operation: "list-dir", params: { path: "." } }) + ); + expect(msg).toBe("Listing directory..."); + }); + }); + + describe("create-sentry-project", () => { + test("shows project name and platform", () => { + const msg = describeLocalOp( + payload({ + operation: "create-sentry-project", + params: { name: "my-app", platform: "python-django" }, + }) + ); + expect(msg).toBe('Creating project "my-app" (python-django)...'); + }); + }); +});