From 9fe06f30da6fbe6085c616ee22c3627fb9e99f57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 6 Jul 2026 20:44:42 -0400 Subject: [PATCH] feat(cli): forward feedback submissions to the backend feedback endpoint (#2003) * feat(cli): forward feedback submissions to backend endpoint * fix(cli): truncate feedback fields to backend caps + ack before forwarding Addresses PR review (via): - Truncate comment (2000) / cli_version (100) / env (500) to the backend DTO caps before POSTing, so a pasted stack trace is forwarded truncated instead of rejected with a 422 the best-effort path swallows silently. - Print "Thanks for the feedback!" before the best-effort forward so the ack isn't blocked behind the (bounded) network call. * fix(cli): type feedback fetch mock --- packages/cli/src/commands/feedback.ts | 4 + packages/cli/src/utils/submitFeedback.test.ts | 88 +++++++++++++++++++ packages/cli/src/utils/submitFeedback.ts | 40 +++++++++ 3 files changed, 132 insertions(+) create mode 100644 packages/cli/src/utils/submitFeedback.test.ts create mode 100644 packages/cli/src/utils/submitFeedback.ts diff --git a/packages/cli/src/commands/feedback.ts b/packages/cli/src/commands/feedback.ts index cc97e15e5..14e97c058 100644 --- a/packages/cli/src/commands/feedback.ts +++ b/packages/cli/src/commands/feedback.ts @@ -7,6 +7,7 @@ import { trackRenderFeedback } from "../telemetry/events.js"; import { shouldTrack, flush } from "../telemetry/client.js"; import { getDoctorSummary } from "../telemetry/feedback.js"; import { publishProjectArchive } from "../utils/publishProject.js"; +import { submitFeedback } from "../utils/submitFeedback.js"; import { buildIssueUrl, HYPERFRAMES_REPO_URL } from "../utils/feedbackIssue.js"; import { VERSION } from "../version.js"; import { c } from "../ui/colors.js"; @@ -164,7 +165,10 @@ export default defineCommand({ trackRenderFeedback({ rating, comment, doctorSummary }); await flush(); + // Ack first so the user isn't kept waiting on the best-effort forward (which + // is bounded to a few seconds and never surfaces an error either way). console.log(c.dim("Thanks for the feedback!")); + await submitFeedback({ rating, comment, cliVersion: VERSION, env: doctorSummary }); if (args["file-issue"] === true) { await fileGithubIssue({ diff --git a/packages/cli/src/utils/submitFeedback.test.ts b/packages/cli/src/utils/submitFeedback.test.ts new file mode 100644 index 000000000..c40b3af47 --- /dev/null +++ b/packages/cli/src/utils/submitFeedback.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const getPublishApiBaseUrlMock = vi.hoisted(() => vi.fn(() => "https://api.example.com")); + +vi.mock("./publishProject.js", () => ({ + getPublishApiBaseUrl: getPublishApiBaseUrlMock, +})); + +import { submitFeedback } from "./submitFeedback.js"; + +describe("submitFeedback", () => { + beforeEach(() => { + getPublishApiBaseUrlMock.mockReturnValue("https://api.example.com"); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it("posts feedback to the backend endpoint", async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 202 })); + vi.stubGlobal("fetch", fetchMock); + + await submitFeedback({ + rating: 4, + comment: "fast but font missing", + cliVersion: "1.2.3", + env: "os=darwin/arm64 node=v22.11.0", + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(getPublishApiBaseUrlMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v1/hyperframes/feedback", + expect.objectContaining({ + method: "POST", + headers: { "content-type": "application/json", heygen_route: "canary" }, + body: JSON.stringify({ + rating: 4, + comment: "fast but font missing", + cli_version: "1.2.3", + env: "os=darwin/arm64 node=v22.11.0", + }), + signal: expect.any(AbortSignal), + }), + ); + }); + + it("truncates over-long fields to the backend caps", async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 202 })); + vi.stubGlobal("fetch", fetchMock); + + await submitFeedback({ + rating: 3, + comment: "x".repeat(2500), + cliVersion: "v".repeat(200), + env: "e".repeat(600), + }); + + const requestInit = fetchMock.mock.calls[0]?.[1]; + expect(requestInit).toBeDefined(); + const body = JSON.parse(requestInit?.body as string); + expect(body.comment).toHaveLength(2000); + expect(body.cli_version).toHaveLength(100); + expect(body.env).toHaveLength(500); + }); + + it("does not reject when fetch rejects", async () => { + const fetchMock = vi.fn().mockRejectedValueOnce(new TypeError("fetch failed")); + vi.stubGlobal("fetch", fetchMock); + + await expect( + submitFeedback({ rating: 1, cliVersion: "1.2.3", env: "os=linux" }), + ).resolves.toBeUndefined(); + }); + + it("always resolves regardless of fetch outcome", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 500 })) + .mockRejectedValueOnce(new Error("offline")); + vi.stubGlobal("fetch", fetchMock); + + await expect(submitFeedback({ rating: 2, cliVersion: "1.2.3" })).resolves.toBeUndefined(); + await expect(submitFeedback({ rating: 3, cliVersion: "1.2.3" })).resolves.toBeUndefined(); + }); +}); diff --git a/packages/cli/src/utils/submitFeedback.ts b/packages/cli/src/utils/submitFeedback.ts new file mode 100644 index 000000000..9c8087871 --- /dev/null +++ b/packages/cli/src/utils/submitFeedback.ts @@ -0,0 +1,40 @@ +import { getPublishApiBaseUrl } from "./publishProject.js"; + +// Match the backend DTO caps (HyperframesFeedbackRequest). Truncate here so an +// over-long field (e.g. a pasted stack trace) is still forwarded truncated, +// rather than rejected by the backend with a 422 the best-effort path swallows. +const MAX_COMMENT = 2000; +const MAX_CLI_VERSION = 100; +const MAX_ENV = 500; + +function cap(value: string | undefined, max: number): string | undefined { + if (value === undefined) return undefined; + return value.length > max ? value.slice(0, max) : value; +} + +export async function submitFeedback(input: { + rating: number; + comment?: string; + cliVersion: string; + env?: string; +}): Promise { + try { + const apiBaseUrl = getPublishApiBaseUrl(); + await fetch(`${apiBaseUrl}/v1/hyperframes/feedback`, { + method: "POST", + body: JSON.stringify({ + rating: input.rating, + comment: cap(input.comment, MAX_COMMENT), + cli_version: cap(input.cliVersion, MAX_CLI_VERSION), + env: cap(input.env, MAX_ENV), + }), + headers: { + "content-type": "application/json", + heygen_route: "canary", + }, + signal: AbortSignal.timeout(5000), + }); + } catch { + // Best-effort only. + } +}