feat(feedback): adopt 0–10 recommendation scale (#2438)

* feat(feedback): adopt 10-point recommendation scale

* docs(feedback): keep OSS scale contract self-contained
This commit is contained in:
Miguel Ángel
2026-07-14 15:46:47 -04:00
committed by GitHub
parent d2c8c2d808
commit 990f5c3145
18 changed files with 103 additions and 43 deletions
+2 -2
View File
@@ -27,7 +27,7 @@ describe("buildIssueUrl", () => {
const url = buildIssueUrl(base);
expect(decoded(url, "title")).toBe("[feedback] GSAP timeline froze on seek");
const body = decoded(url, "body");
expect(body).toContain("2/5");
expect(body).toContain("2/10");
expect(body).toContain("https://hyperframes.dev/p/abc123");
expect(body).toContain("os=darwin/arm64");
expect(body).toContain("cli=1.2.3");
@@ -35,7 +35,7 @@ describe("buildIssueUrl", () => {
it("falls back to a generic title when there is no comment", () => {
const url = buildIssueUrl({ ...base, comment: undefined });
expect(decoded(url, "title")).toBe("Render feedback (rating 2/5)");
expect(decoded(url, "title")).toBe("Render feedback (rating 2/10)");
});
it("truncates an overlong comment in the body", () => {
+4 -2
View File
@@ -1,3 +1,5 @@
import { FEEDBACK_RATING_SCALE } from "./feedbackRating.js";
// Reading package.json at runtime from the single-file bundled CLI is awkward,
// so we keep the canonical repo as a constant. It must match the `repository.url`
// in packages/cli/package.json.
@@ -33,7 +35,7 @@ function truncate(value: string, max: number): string {
function buildIssueTitle(rating: number, comment?: string): string {
const firstLine = comment?.split("\n")[0]?.trim();
if (!firstLine) return `Render feedback (rating ${rating}/5)`;
if (!firstLine) return `Render feedback (rating ${rating}/${FEEDBACK_RATING_SCALE})`;
return `[feedback] ${truncate(firstLine, TITLE_MAX)}`;
}
@@ -44,7 +46,7 @@ function buildIssueBody(input: IssueInput): string {
: "_Publishing the repro failed, no public link available._";
return [
`**Rating:** ${input.rating}/5`,
`**Rating:** ${input.rating}/${FEEDBACK_RATING_SCALE}`,
"",
"## Comment",
comment ? truncate(comment, COMMENT_MAX) : "_No comment provided._",
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { FEEDBACK_RATING_SCALE, parseFeedbackRating } from "./feedbackRating.js";
describe("parseFeedbackRating", () => {
it.each(["0", "10"])("accepts the NPS boundary %s", (raw) => {
expect(parseFeedbackRating(raw)).toBe(Number(raw));
});
it.each(["-1", "11", "4.5", "10x", ""])("rejects invalid rating %j", (raw) => {
expect(parseFeedbackRating(raw)).toBeNull();
});
it("declares the serialized rating scale", () => {
expect(FEEDBACK_RATING_SCALE).toBe(10);
});
});
+8
View File
@@ -0,0 +1,8 @@
export const FEEDBACK_RATING_SCALE = 10;
export function parseFeedbackRating(raw: string): number | null {
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed)) return null;
const rating = Number(trimmed);
return Number.isInteger(rating) && rating >= 0 && rating <= FEEDBACK_RATING_SCALE ? rating : null;
}
@@ -38,6 +38,7 @@ describe("submitFeedback", () => {
headers: { "content-type": "application/json", heygen_route: "canary" },
body: JSON.stringify({
rating: 4,
rating_scale: 10,
comment: "fast but font missing",
cli_version: "1.2.3",
env: "os=darwin/arm64 node=v22.11.0",
@@ -47,6 +48,17 @@ describe("submitFeedback", () => {
);
});
it.each([0, 10])("serializes the NPS boundary %i without changing it", async (rating) => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(null, { status: 202 }));
vi.stubGlobal("fetch", fetchMock);
await submitFeedback({ rating, cliVersion: "1.2.3" });
const requestInit = fetchMock.mock.calls[0]?.[1];
const body = JSON.parse(requestInit?.body as string);
expect(body).toMatchObject({ rating, rating_scale: 10 });
});
it("truncates over-long fields to the backend caps", async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(null, { status: 202 }));
vi.stubGlobal("fetch", fetchMock);
+2
View File
@@ -1,4 +1,5 @@
import { getPublishApiBaseUrl } from "./publishProject.js";
import { FEEDBACK_RATING_SCALE } from "./feedbackRating.js";
// Match the backend DTO caps (HyperframesFeedbackRequest). Truncate here so an
// over-long field (e.g. a pasted stack trace) is still forwarded truncated,
@@ -24,6 +25,7 @@ export async function submitFeedback(input: {
method: "POST",
body: JSON.stringify({
rating: input.rating,
rating_scale: FEEDBACK_RATING_SCALE,
comment: cap(input.comment, MAX_COMMENT),
cli_version: cap(input.cliVersion, MAX_CLI_VERSION),
env: cap(input.env, MAX_ENV),