diff --git a/packages/cli/src/utils/errorMessage.test.ts b/packages/cli/src/utils/errorMessage.test.ts new file mode 100644 index 000000000..19fb7fcc8 --- /dev/null +++ b/packages/cli/src/utils/errorMessage.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { normalizeErrorMessage } from "./errorMessage.js"; + +describe("normalizeErrorMessage", () => { + it("returns the message of an Error instance", () => { + expect(normalizeErrorMessage(new Error("render failed"))).toBe("render failed"); + }); + + it("returns the message of an Error subclass", () => { + expect(normalizeErrorMessage(new TypeError("bad input"))).toBe("bad input"); + }); + + it("passes a plain string through", () => { + expect(normalizeErrorMessage("already a message")).toBe("already a message"); + }); + + it("prefers a string message property on plain objects", () => { + expect(normalizeErrorMessage({ message: "from object", code: 500 })).toBe("from object"); + }); + + it("stringifies plain objects without a message property", () => { + expect(normalizeErrorMessage({ status: 503, retryable: true })).toBe( + '{"status":503,"retryable":true}', + ); + }); + + it("stringifies objects whose message property is not a string", () => { + expect(normalizeErrorMessage({ message: 42 })).toBe('{"message":42}'); + }); + + it("falls back to a key list when JSON.stringify throws on a cycle", () => { + const cyclic: Record = { code: "E_LOOP" }; + cyclic["self"] = cyclic; + expect(normalizeErrorMessage(cyclic)).toBe("{code, self}"); + }); + + it("falls back to String() when an object resists both JSON and key listing", () => { + const hostile = new Proxy( + {}, + { + ownKeys() { + throw new Error("no keys for you"); + }, + }, + ); + expect(normalizeErrorMessage(hostile)).toBe("[object Object]"); + }); + + it("returns 'unknown error' for null and undefined", () => { + expect(normalizeErrorMessage(null)).toBe("unknown error"); + expect(normalizeErrorMessage(undefined)).toBe("unknown error"); + }); + + it("stringifies non-object primitives", () => { + expect(normalizeErrorMessage(42)).toBe("42"); + expect(normalizeErrorMessage(false)).toBe("false"); + }); +}); diff --git a/packages/cli/src/utils/lintFormat.test.ts b/packages/cli/src/utils/lintFormat.test.ts new file mode 100644 index 000000000..f967c753a --- /dev/null +++ b/packages/cli/src/utils/lintFormat.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; +import type { HyperframeLintFinding } from "@hyperframes/core/lint"; +import { formatLintFindings } from "./lintFormat.js"; +import type { ProjectLintResult } from "./lintProject.js"; + +function finding( + severity: HyperframeLintFinding["severity"], + overrides: Partial = {}, +): HyperframeLintFinding { + return { code: `${severity}-code`, severity, message: `${severity} message`, ...overrides }; +} + +function project( + files: Array<{ file: string; findings: HyperframeLintFinding[] }>, +): ProjectLintResult { + const all = files.flatMap((f) => f.findings); + const count = (severity: HyperframeLintFinding["severity"]) => + all.filter((f) => f.severity === severity).length; + return { + results: files.map(({ file, findings }) => ({ + file, + result: { + ok: findings.every((f) => f.severity !== "error"), + errorCount: findings.filter((f) => f.severity === "error").length, + warningCount: findings.filter((f) => f.severity === "warning").length, + infoCount: findings.filter((f) => f.severity === "info").length, + findings, + }, + })), + totalErrors: count("error"), + totalWarnings: count("warning"), + totalInfos: count("info"), + }; +} + +describe("formatLintFindings", () => { + it("returns no lines when no file has findings", () => { + expect(formatLintFindings(project([{ file: "index.html", findings: [] }]))).toEqual([]); + }); + + it("renders code, message, and elementId without a file label for a single file", () => { + const lines = formatLintFindings( + project([ + { + file: "index.html", + findings: [ + finding("error", { + code: "missing-timeline", + message: "no timeline registered", + elementId: "main", + }), + ], + }, + ]), + ); + + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("✗"); + expect(lines[0]).toContain("missing-timeline"); + expect(lines[0]).toContain("[main]"); + expect(lines[0]).toContain("no timeline registered"); + expect(lines[0]).not.toContain("index.html"); + }); + + it("labels each finding with its file when more than one file has results", () => { + const lines = formatLintFindings( + project([ + { file: "index.html", findings: [finding("error")] }, + { file: "compositions/scene.html", findings: [finding("warning")] }, + ]), + ); + + expect(lines).toHaveLength(2); + expect(lines[0]).toContain("[index.html]"); + expect(lines[1]).toContain("[compositions/scene.html]"); + }); + + it("hides the elementId when showElementId is false", () => { + const result = project([ + { file: "index.html", findings: [finding("error", { elementId: "main" })] }, + ]); + + const lines = formatLintFindings(result, { showElementId: false }); + expect(lines[0]).not.toContain("[main]"); + }); + + it("marks warnings with their own prefix and skips info findings by default", () => { + const lines = formatLintFindings( + project([ + { + file: "index.html", + findings: [finding("warning"), finding("info")], + }, + ]), + ); + + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("⚠"); + expect(lines[0]).toContain("warning message"); + }); + + it("includes info findings when verbose", () => { + const lines = formatLintFindings( + project([{ file: "index.html", findings: [finding("info")] }]), + { verbose: true }, + ); + + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("ℹ"); + expect(lines[0]).toContain("info message"); + }); + + it("keeps the input order by default", () => { + const lines = formatLintFindings( + project([ + { + file: "index.html", + findings: [finding("warning"), finding("error")], + }, + ]), + ); + + expect(lines).toHaveLength(2); + expect(lines[0]).toContain("warning message"); + expect(lines[1]).toContain("error message"); + }); + + it("groups errors before warnings per file when errorsFirst is set", () => { + const lines = formatLintFindings( + project([ + { + file: "index.html", + findings: [finding("warning"), finding("error")], + }, + ]), + { errorsFirst: true }, + ); + + expect(lines).toHaveLength(2); + expect(lines[0]).toContain("error message"); + expect(lines[1]).toContain("warning message"); + }); + + it("places info findings last under errorsFirst with verbose", () => { + const lines = formatLintFindings( + project([ + { + file: "index.html", + findings: [finding("info"), finding("warning"), finding("error")], + }, + ]), + { errorsFirst: true, verbose: true }, + ); + + expect(lines).toHaveLength(3); + expect(lines[0]).toContain("error message"); + expect(lines[1]).toContain("warning message"); + expect(lines[2]).toContain("info message"); + }); + + it("appends an indented fix hint line directly after its finding", () => { + const lines = formatLintFindings( + project([ + { + file: "index.html", + findings: [finding("error", { fixHint: "add data-composition-id" }), finding("warning")], + }, + ]), + ); + + expect(lines).toHaveLength(3); + expect(lines[0]).toContain("error message"); + expect(lines[1]).toContain("Fix: add data-composition-id"); + expect(lines[2]).toContain("warning message"); + }); + + it("appends a summary line with totals when showSummary is set", () => { + const lines = formatLintFindings( + project([ + { + file: "index.html", + findings: [finding("error"), finding("error"), finding("warning")], + }, + ]), + { showSummary: true }, + ); + + expect(lines.at(-2)).toBe(""); + expect(lines.at(-1)).toContain("2 error(s), 1 warning(s)"); + }); + + it("includes the info count in the summary only when verbose", () => { + const result = project([ + { + file: "index.html", + findings: [finding("error"), finding("info")], + }, + ]); + + const quiet = formatLintFindings(result, { showSummary: true }); + expect(quiet.at(-1)).not.toContain("info(s)"); + + const verbose = formatLintFindings(result, { showSummary: true, verbose: true }); + expect(verbose.at(-1)).toContain("1 error(s), 0 warning(s), 1 info(s)"); + }); +});