Public: anyone can read this; the crew can change it.
A check's summary, test row and error line are cut with `clip`, so a failing test named with an emoji reads whole #6764
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6629-a-check-s-summary-test-row-and-error-lin → main 1 commit fork
5 files
changed +52
−10
src/checks/summary.ts +2 −5
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | import type { CheckDetail, Run } from "../core/runs.ts"; |
| 11 | 11 | import { parseBunTestFailure } from "./bun-test.ts"; |
| 12 | 12 | import { oneLine } from "./words.ts"; |
| 13 | import { clip } from "../text/clip.ts"; | |
| 13 | 14 | import { webvitals, type Entries } from "../release/webvitals.ts"; |
| 14 | 15 | |
| 15 | 16 | /** |
| @@ -26,11 +27,7 @@ const UNNAMED_SHARD = | ||
| 26 | 27 | /** A summary is one line on a card; a name from a subprocess is bounded to fit it. */ |
| 27 | 28 | const SUMMARY_MAX = 200; |
| 28 | 29 | |
| 29 | function truncate(text: string): string { | |
| 30 | return text.length > SUMMARY_MAX | |
| 31 | ? `${text.slice(0, SUMMARY_MAX - 1)}…` | |
| 32 | : text; | |
| 33 | } | |
| 30 | const truncate = (text: string): string => clip(text, SUMMARY_MAX); | |
| 34 | 31 | |
| 35 | 32 | export interface CheckFailure { |
| 36 | 33 | /** The one line a card shows, already bounded. */ |
src/checks/tests-row.ts +2 −2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { parseShardProgress, type CheckProgress } from "./progress.ts"; |
| 11 | import { clip } from "../text/clip.ts"; | |
| 11 | 12 | |
| 12 | 13 | export type PinnedFailure = { |
| 13 | 14 | readonly name: string; |
| @@ -35,8 +36,7 @@ export const PIN_LINES = 3; | ||
| 35 | 36 | const LINE_MAX = 200; |
| 36 | 37 | |
| 37 | 38 | const names = (text: string): string[] => text.split(" ").filter((n) => n !== ""); |
| 38 | const bound = (text: string): string => | |
| 39 | text.length > LINE_MAX ? `${text.slice(0, LINE_MAX - 1)}…` : text; | |
| 39 | const bound = (text: string): string => clip(text, LINE_MAX); | |
| 40 | 40 | |
| 41 | 41 | export function parseTestsRow(lines: readonly string[]): TestsRow { |
| 42 | 42 | const base = parseShardProgress(lines); |
src/checks/words.ts +3 −2
| @@ -1,8 +1,9 @@ | ||
| 1 | import { clip } from "../text/clip.ts"; | |
| 2 | ||
| 1 | 3 | /** One line of a failure's summary on a narrow screen; the rest is in the run. */ |
| 2 | 4 | const LINE_MAX = 80; |
| 3 | 5 | |
| 4 | 6 | /** `text` cut to one line of at most `LINE_MAX` characters, with an ellipsis. */ |
| 5 | 7 | export function oneLine(text: string): string { |
| 6 | const flat = text.replace(/\s+/g, " ").trim(); | |
| 7 | return flat.length > LINE_MAX ? `${flat.slice(0, LINE_MAX - 1)}…` : flat; | |
| 8 | return clip(text, LINE_MAX); | |
| 8 | 9 | } |
src/runs/error_line.ts +3 −1
| @@ -6,6 +6,8 @@ | ||
| 6 | 6 | * opens a log. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { clip } from "../text/clip.ts"; | |
| 10 | ||
| 9 | 11 | /** bun's `(fail)`, `error:`, `Error`, `failed`, `failure` — as words, any case. */ |
| 10 | 12 | const MARKER = /\(fail\)|\b(?:error|fail(?:ed|ure|s)?)\b/i; |
| 11 | 13 | |
| @@ -22,7 +24,7 @@ export function firstErrorLine(lines: readonly string[]): ErrorLine | undefined | ||
| 22 | 24 | for (let i = 0; i < lines.length; i++) { |
| 23 | 25 | const text = lines[i]!.trim(); |
| 24 | 26 | if (text !== "" && MARKER.test(text)) |
| 25 | return { text: text.length > ERROR_LINE_MAX ? `${text.slice(0, ERROR_LINE_MAX - 1)}…` : text, at: i + 1 }; | |
| 27 | return { text: clip(text, ERROR_LINE_MAX), at: i + 1 }; | |
| 26 | 28 | } |
| 27 | 29 | return undefined; |
| 28 | 30 | } |
test/text-clip-checks.test.ts added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { ERROR_LINE_MAX, firstErrorLine } from "../src/runs/error_line.ts"; | |
| 3 | import { oneLine } from "../src/checks/words.ts"; | |
| 4 | import { parseTestsRow } from "../src/checks/tests-row.ts"; | |
| 5 | import { summariseCheckFailure } from "../src/checks/summary.ts"; | |
| 6 | ||
| 7 | const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/; | |
| 8 | ||
| 9 | describe("the lines a red check is explained by are cut at a code point", () => { | |
| 10 | test("error_line cuts a 300-character line with an emoji straddling its limit and leaves no lone surrogate", () => { | |
| 11 | // The emoji's high surrogate sits at index ERROR_LINE_MAX - 2, so a | |
| 12 | // UTF-16 cut at ERROR_LINE_MAX - 1 would split it. | |
| 13 | const head = "error: " + "x".repeat(ERROR_LINE_MAX - 2 - 7); | |
| 14 | const line = head + "🔥" + "y".repeat(300 - head.length - 2); | |
| 15 | expect(line.length).toBe(300); | |
| 16 | const got = firstErrorLine([line])!; | |
| 17 | expect(got.at).toBe(1); | |
| 18 | expect(LONE_SURROGATE.test(got.text)).toBe(false); | |
| 19 | expect(got.text.endsWith("…")).toBe(true); | |
| 20 | expect(Array.from(got.text).length).toBe(ERROR_LINE_MAX); | |
| 21 | }); | |
| 22 | ||
| 23 | test("a failure's one line keeps an emoji whole at its limit", () => { | |
| 24 | const text = "x".repeat(78) + "🔥" + "y".repeat(40); | |
| 25 | const got = oneLine(text); | |
| 26 | expect(LONE_SURROGATE.test(got)).toBe(false); | |
| 27 | expect(Array.from(got).length).toBe(80); | |
| 28 | }); | |
| 29 | ||
| 30 | test("a pinned failing test named with an emoji reads whole", () => { | |
| 31 | const name = "z".repeat(198) + "🔥 tail"; | |
| 32 | const row = parseTestsRow([`(fail) ${name}`]); | |
| 33 | expect(LONE_SURROGATE.test(row.failures[0]!.name)).toBe(false); | |
| 34 | }); | |
| 35 | ||
| 36 | test("a summary cut through an emoji leaves no lone surrogate", () => { | |
| 37 | const line = "w".repeat(198) + "🔥" + "v".repeat(20); | |
| 38 | const got = summariseCheckFailure([line], "check exited with code 1"); | |
| 39 | expect(LONE_SURROGATE.test(got.summary)).toBe(false); | |
| 40 | expect(Array.from(got.summary).length).toBe(200); | |
| 41 | }); | |
| 42 | }); |
new above
offline