diff --git a/package-lock.json b/package-lock.json index 0912f37..6da283c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,6 @@ "@11ty/eleventy": "^3.1.5", "@11ty/eleventy-navigation": "^1.0.5", "@stylistic/eslint-plugin": "latest", - "chalk": "^5.6.2", "eleventy-plugin-toc": "^1.1.5", "eslint": "latest", "globals": "latest", diff --git a/package.json b/package.json index c7dfa92..bc1ec1e 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "@11ty/eleventy": "^3.1.5", "@11ty/eleventy-navigation": "^1.0.5", "@stylistic/eslint-plugin": "latest", - "chalk": "^5.6.2", "eleventy-plugin-toc": "^1.1.5", "eslint": "latest", "globals": "latest", diff --git a/src/classes/TestResult.js b/src/classes/TestResult.js index 3b29c9f..6a27753 100644 --- a/src/classes/TestResult.js +++ b/src/classes/TestResult.js @@ -1,6 +1,6 @@ import Test from "./Test.js"; import BubblingEventTarget from "./BubblingEventTarget.js"; -import { stripFormatting } from "../format-console.js"; +import { stripFormatting } from "../util/format-console.js"; import { delay, formatDuration, interceptConsole, pluralize, stringify } from "../util.js"; import { formatDiff } from "../util/format-diff.js"; @@ -358,11 +358,11 @@ ${ this.error.stack }`); * @returns {string} */ getResult (o) { - let color = this.pass ? "green" : this.skipped ? "yellow" : "red"; let label = this.pass ? "PASS" : this.skipped ? "SKIP" : "FAIL"; + let color = label.toLowerCase(); let ret = [ - ` ${ label } `, - `${this.name ?? "(Anonymous)"}`, + ` ${ label } `, + `${this.name ?? "(Anonymous)"}`, ].join(" "); if (this.messages?.length > 0) { @@ -392,11 +392,11 @@ ${ this.error.stack }`); ]; if (stats.pass > 0) { - ret.push(`${ stats.pass }/${ stats.total } PASS`); + ret.push(`${ stats.pass }/${ stats.total } PASS`); } if (stats.fail > 0) { - ret.push(`${ stats.fail }/${ stats.total } FAIL`); + ret.push(`${ stats.fail }/${ stats.total } FAIL`); } if (stats.pending > 0) { @@ -431,7 +431,7 @@ ${ this.error.stack }`); * @returns {string} */ getMessages (o = {}) { - let ret = new String("(Messages)"); + let ret = new String("(Messages)"); ret.children = this.messages.map(m => `(${ m.method }) ${ m.args.map(a => stringify(a)).join(" ") }`); return o?.format === "rich" ? ret : stripFormatting(ret); diff --git a/src/env/console.js b/src/env/console.js index 987bff2..38ac6ca 100644 --- a/src/env/console.js +++ b/src/env/console.js @@ -1,15 +1,15 @@ -import format from "../format-console.js"; +import format from "../util/format-console.js"; function printTree (str, parent) { if (str.children?.length > 0) { - console["group" + (parent ? "Collapsed" : "")](format(str)); + console["group" + (parent ? "Collapsed" : "")](...format(str, "css")); for (let child of str.children) { printTree(child, str); } console.groupEnd(); } else { - console.log(format(str)); + console.log(...format(str, "css")); } } diff --git a/src/env/node.js b/src/env/node.js index b96c764..94f17ee 100644 --- a/src/env/node.js +++ b/src/env/node.js @@ -10,7 +10,7 @@ import { AsciiTree } from "oo-ascii-tree"; import { globSync } from "glob"; // Internal modules -import format from "../format-console.js"; +import format from "../util/format-console.js"; import { getType } from "../util.js"; /** @@ -60,7 +60,7 @@ function getTree (msg, i) { let icon = collapsed ? icons.collapsed : icons.expanded; if (highlighted) { - icon = `${ collapsed ? icons.collapsedHighlighted : icons.expandedHighlighted }`; + icon = `${ collapsed ? icons.collapsedHighlighted : icons.expandedHighlighted }`; msg = `${ msg }`; } msg = icon + " " + msg; diff --git a/src/format-console.js b/src/format-console.js deleted file mode 100644 index 879df41..0000000 --- a/src/format-console.js +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Format console text with HTML-like tags - */ -// https://stackoverflow.com/a/41407246/90826 -let modifiers = { - reset: "\x1b[0m", - b: "\x1b[1m", - dim: "\x1b[2m", - i: "\x1b[3m", -}; - -let hues = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]; - -let colors = Object.fromEntries(hues.map((hue, i) => [hue, `\x1b[3${i}m`])); -let bgColors = Object.fromEntries(hues.map((hue, i) => [hue, `\x1b[4${i}m`])); - -function getColorCode (hue, {light, bg} = {}) { - if (!hue) { - return ""; - } - if (hue.startsWith("light")) { - hue = hue.replace("light", ""); - light = true; - } - let i = hues.indexOf(hue); - - if (i === -1) { - return ""; - } - - if (light) { - return `\x1b[${ bg ? 10 : 9 }${i}m`; - } - - return `\x1b[${ light ? "1;" : ""}${ bg ? 4 : 3 }${i}m`; -} - -let tags = [ - Object.keys(modifiers).map(tag => ``), - ``, ``, - ``, ``, -]; -let tagRegex = RegExp(tags.flat().join("|"), "gi"); - -export default function format (str) { - if (!str) { - return str; - } - - str = str + ""; - // Iterate over all regex matches in str - let active = new Set(); - let colorStack = []; - let bgStack = []; - return str.replace(tagRegex, tag => { - let isClosing = tag[1] === "/"; - let name = tag.match(/<\/?(\w+)/)[1]; - let color = tag.match(/<(?:bg|c)\s+(\w+)>/)?.[1]; - - if (isClosing) { - if (name === "c") { - colorStack.pop(); - } - else if (name === "bg") { - bgStack.pop(); - } - else if (active.has(name)) { - active.delete(name); - } - else { - // Closing tag for formatting that wasn't active - return ""; - } - - let activeColor = colorStack.at(-1); - let colorModifier = getColorCode(activeColor); - let activeBg = bgStack.at(-1); - let bgColorModifier = getColorCode(activeBg, {bg: true}); - return modifiers.reset + [...active].map(name => modifiers[name]).join("") + colorModifier + bgColorModifier; - } - else { - if (name === "c") { - colorStack.push(color); - return getColorCode(color); - } - else if (name === "bg") { - bgStack.push(color); - return getColorCode(color, {bg: true}); - } - else { - active.add(name); - return modifiers[name]; - } - } - }); -} - -export function stripFormatting (str) { - return str.replace(tagRegex, ""); -} - -// /** -// * Platform agnostic console formatting -// * @param {*} str -// * @param {*} format -// */ -// export default function format (str, format) { -// if (typeof format === "string") { -// format = Object.fromEntries(format.split(/\s+/).map(type => [type, true])); -// } - -// for (let type in format) { -// str = formats[type] ? formats[type](str) : str; -// } -// str = str.replaceAll("\x1b", "\\x1b"); -// return str; -// } diff --git a/src/render.js b/src/render.js index f44be94..1e9fd78 100644 --- a/src/render.js +++ b/src/render.js @@ -7,7 +7,7 @@ import TestResult from "./classes/TestResult.js"; import RefTest from "https://html.htest.dev/src/classes/RefTest.js"; import { create, output } from "https://html.htest.dev/src/util.js"; import { formatDuration } from "./util.js"; -import format from "./format-console.js"; +import format from "./util/format-console.js"; export default function render (test) { let root = new Test(test); @@ -83,7 +83,7 @@ export default function render (test) { } else if (!target.pass) { cell.classList.add("details"); - cell.onclick = () => console.log(target.details.map(format).join("\n")); + cell.onclick = () => console.log(...format(target.details.join("\n"))); } tr.dataset.time = formatDuration(target.timeTaken); } diff --git a/src/util/format-console.js b/src/util/format-console.js new file mode 100644 index 0000000..104edc4 --- /dev/null +++ b/src/util/format-console.js @@ -0,0 +1,262 @@ +/** + * Format console text with HTML-like tags. + * + * Modes: + * - "truecolor" / "256" / "strip" — ANSI backend (default on Node). + * - "css" — returns [text, ...styles] for console.log spread (default on browser). + */ + +import { IS_NODEJS } from "../util.js"; +import palette from "./palette.js"; + +// `reset` is internal-only — not matched by tagRegex, used by emitAnsi's replay. +const modifiers = { + reset: { ansi: "\x1b[0m" }, + b: { ansi: "\x1b[1m", css: "font-weight: bold" }, + i: { ansi: "\x1b[3m", css: "font-style: italic" }, + dim: { ansi: "\x1b[2m", css: "opacity: 0.6" }, +}; + +const tagRegex = /<\/?(b|i|dim|c|bg)(?:\s+([\w#-]+))?\s*>/gi; + +/** + * Detect format mode from an env-like object. Browser falls back to "css" (CSS backend). + * Pure — takes env as argument for testability; default path is cached in `detectedMode`. + * @param {Record | null} [env] + * @returns {"truecolor" | "256" | "strip" | "css"} + */ +function detectMode (env = IS_NODEJS ? process.env : null) { + if (!env) { + return "css"; + } + + let ret = "256"; // env.FORCE_COLOR === "2" || env.FORCE_COLOR === "1" || no env; + + if (env.NO_COLOR || env.FORCE_COLOR === "0") { + ret = "strip"; + } + else if ( + env.FORCE_COLOR === "3" || + env.COLORTERM === "truecolor" || + env.COLORTERM === "24bit" || + /-truecolor|-direct|-24bit/.test(env.TERM ?? "") + ) { + ret = "truecolor"; + } + + return ret; +} + +const detectedMode = detectMode(); + +/** + * Resolve a color name to a hex value. + * Accepts semantic tokens, base names, and hex literals. + */ +function resolveColor (name) { + if (palette[name]) { + return palette[name]; + } + if (/^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(name)) { + return name; + } + return null; +} + +function parseHex (hex) { + hex = hex.replace("#", ""); + if (hex.length === 3) { + hex = hex + .split("") + .map(c => c + c) + .join(""); + } + return { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16), + }; +} + +export function ansiTruecolor (hex, { bg } = {}) { + let { r, g, b } = parseHex(hex); + return `\x1b[${bg ? 48 : 38};2;${r};${g};${b}m`; +} + +// Reference points of the xterm 6×6×6 color cube (indices 16–231). +const cubeLevels = [0, 95, 135, 175, 215, 255]; + +function quantize (value) { + let best = 0; + let bestDelta = Infinity; + for (let i = 0; i < cubeLevels.length; i++) { + let delta = Math.abs(value - cubeLevels[i]); + if (delta < bestDelta) { + bestDelta = delta; + best = i; + } + } + return best; +} + +export function ansi256 (hex, { bg } = {}) { + let { r, g, b } = parseHex(hex); + let index = 16 + 36 * quantize(r) + 6 * quantize(g) + quantize(b); + return `\x1b[${bg ? 48 : 38};5;${index}m`; +} + +/** + * Tokenize a tagged string into a stream of { type, tag, value } records. + */ +function tokenize (str) { + let tokens = []; + let lastIndex = 0; + for (let match of str.matchAll(tagRegex)) { + if (match.index > lastIndex) { + tokens.push({ type: "text", value: str.slice(lastIndex, match.index) }); + } + let isClose = match[0].startsWith(" ""; + + let replay = () => { + output += modifiers.reset.ansi; + for (let modifier of activeModifiers) { + output += modifiers[modifier].ansi; + } + let color = colorStack.findLast(Boolean); + if (color) { + output += emitColor(color); + } + let bg = bgStack.findLast(Boolean); + if (bg) { + output += emitColor(bg, { bg: true }); + } + }; + + for (let token of tokens) { + if (token.type === "text") { + output += token.value; + continue; + } + + let isBg = token.tag === "bg"; + let isColor = token.tag === "c" || isBg; + let stack = isBg ? bgStack : colorStack; + + if (token.type === "open") { + if (isColor) { + let hex = resolveColor(token.value); + // Push even when null so close's pop stays balanced. + stack.push(hex); + if (hex) { + output += emitColor(hex, { bg: isBg }); + } + } + else { + activeModifiers.add(token.tag); + output += modifiers[token.tag].ansi; + } + } + else { + if (isColor) { + stack.pop(); + } + else { + activeModifiers.delete(token.tag); + } + // ANSI has no "close this color" code, so reset and replay remaining state. + replay(); + } + } + return output; +} + +function emitCss (tokens) { + let text = ""; + let styles = []; + let activeModifiers = new Set(); + let colorStack = []; + let bgStack = []; + + let pushStyle = () => { + let parts = []; + for (let modifier of activeModifiers) { + parts.push(modifiers[modifier].css); + } + let color = colorStack.findLast(Boolean); + if (color) { + parts.push(`color: ${color}`); + } + let bg = bgStack.findLast(Boolean); + if (bg) { + parts.push(`background: ${bg}`); + } + text += "%c"; + styles.push(parts.join("; ")); + }; + + for (let token of tokens) { + if (token.type === "text") { + text += token.value; + continue; + } + + let isBg = token.tag === "bg"; + let isColor = token.tag === "c" || isBg; + let isOpen = token.type === "open"; + + if (isColor) { + let stack = isBg ? bgStack : colorStack; + if (isOpen) { + stack.push(resolveColor(token.value)); + } + else { + stack.pop(); + } + } + else if (isOpen) { + activeModifiers.add(token.tag); + } + else { + activeModifiers.delete(token.tag); + } + pushStyle(); + } + + return [text, ...styles]; +} + +/** + * Format a tagged string for the target mode. + * @param {string} str + * @param {"truecolor" | "256" | "strip" | "css"} [mode] + * @returns {string | [string, ...string[]]} + */ +export default function format (str, mode = detectedMode) { + let tokens = tokenize(String(str ?? "")); + return mode === "css" ? emitCss(tokens) : emitAnsi(tokens, mode); +} + +export function stripFormatting (str) { + return String(str).replace(tagRegex, ""); +} diff --git a/src/util/format-diff.js b/src/util/format-diff.js index aebb98e..3de6e14 100644 --- a/src/util/format-diff.js +++ b/src/util/format-diff.js @@ -1,5 +1,5 @@ import { IS_NODEJS, getType, pluralize, stringify } from "../util.js"; -import { stripFormatting } from "../format-console.js"; +import { stripFormatting } from "./format-console.js"; // Dual Node/browser import. Kept version in the CDN URL in sync with package.json. // `diffWordsWithSpace` keeps whitespace as part of token boundaries so the @@ -18,10 +18,10 @@ const CONTEXT = 2; /** Longer single-line text switches to two-line word-diff layout. */ const INLINE_MAX = 40; -/** Per-side color, change-action key, and output label for diff formatting. */ +/** Per-side chunk/line bg tokens, change-action key, and output label for diff formatting. */ const sides = { - actual: { color: "red", action: "removed", label: " Actual: " }, - expected: { color: "green", action: "added", label: " Expected: " }, + actual: { chunk: "diff-removed-tint", line: "diff-removed", action: "removed", label: " Actual: " }, + expected: { chunk: "diff-added-tint", line: "diff-added", action: "added", label: " Expected: " }, }; /** @@ -246,16 +246,16 @@ function lineDiff (actualString, expectedString, unmapped) { } /** - * Format one side of a change array. Every changed run gets `` + * Format one side of a change array. Every changed run gets `` * so all diffs — whitespace, token, or char — carry the same visual primitive. * * Without `prefix`: returns mixed common/changed text; caller decides line framing. - * With `prefix`: wraps the whole line in neutral `` with `prefix` - * in front; chunk bgs inside stack over the neutral line bg so changed chars - * pop while the rest of the line keeps a faint "this line changed" tint. + * With `prefix`: wraps the whole line in `` with `prefix` in front; + * chunk bgs inside stack over the line bg so changed chars pop while the rest + * of the line keeps a faint "this line changed" tint. */ function colorize (changes, side, prefix) { - let { color, action } = sides[side]; + let { chunk, line, action } = sides[side]; let ret = ""; for (let change of changes) { @@ -264,14 +264,14 @@ function colorize (changes, side, prefix) { } if (change[action]) { - ret += `${ change.value }`; + ret += `${ change.value }`; } else { ret += change.value; } } - return prefix ? `${ prefix } ${ ret }` : ret; + return prefix ? `${ prefix } ${ ret }` : ret; } /** diff --git a/src/util/palette.js b/src/util/palette.js new file mode 100644 index 0000000..0b923ee --- /dev/null +++ b/src/util/palette.js @@ -0,0 +1,43 @@ +/** + * Color palette: base 16 ANSI-named colors + semantic tokens. + * All values as hex literals (no runtime conversion). + */ + +const base = { + black: "#1e1e2e", + red: "#f38ba8", + green: "#4ade80", + yellow: "#f9e2af", + blue: "#89b4fa", + magenta: "#cba6f7", + cyan: "#94e2d5", + white: "#cdd6f4", + lightblack: "#585b70", + lightred: "#eba0ac", + lightgreen: "#b5e3a7", + lightyellow: "#faedc4", + lightblue: "#a0bff9", + lightmagenta: "#d4b8f7", + lightcyan: "#a9e3d3", + lightwhite: "#e6edf5", +}; + +const semantic = { + pass: base.green, + "pass-tint": base.lightgreen, + fail: base.red, + "fail-tint": base.lightred, + skip: "#a0a8b4", + "skip-tint": "#c4c9d2", + message: base.yellow, + "message-tint": base.lightyellow, + highlight: base.green, + text: base.black, + "diff-added": "#2e4b3a", + "diff-added-tint": base.lightgreen, + "diff-removed": "#4b2e38", + "diff-removed-tint": base.lightred, + gutter: "#313244", +}; + +export default { ...base, ...semantic }; diff --git a/tests/format-console.js b/tests/format-console.js index acc0747..be39afc 100644 --- a/tests/format-console.js +++ b/tests/format-console.js @@ -1,61 +1,182 @@ -import format, { stripFormatting } from "../src/format-console.js"; -import chalk from "chalk"; +import format, { + stripFormatting, + ansiTruecolor, + ansi256, +} from "../src/util/format-console.js"; +import palette from "../src/util/palette.js"; -// We don't want to use map because it will output unmapped values on fail as well, causing a mess in this very special case +// Escape ANSI escape codes so failure output shows them as visible characters. +// We don't want to use map because it will output unmapped values on fail as +// well, causing a mess in this very special case. function escape (str) { return str.replaceAll("\x1b", "\\x1b"); } +const RESET = "\\x1b[0m"; +const color = hex => escape(ansiTruecolor(hex)); +const bgColor = hex => escape(ansiTruecolor(hex, { bg: true })); +const color256 = hex => escape(ansi256(hex)); + export default { - name: "Console formatting tests", + name: "format-console", tests: [ { - name: "Formatting", - run (str) { - return escape(format(str)); + name: "format()", + run (arg) { + let result = format(arg, this.data?.mode); + return typeof result === "string" ? escape(result) : result; }, tests: [ { - name: "Bold", - args: "bold", - expect: "\\x1b[1mbold\\x1b[0m", - }, - { - name: "Text color", - args: "red", - expect: "\\x1b[31mred\\x1b[0m", + name: "Truecolor", + data: { mode: "truecolor" }, + tests: [ + { + name: "Bold modifier", + arg: "x", + expect: `\\x1b[1mx${RESET}`, + }, + { + name: "Semantic token", + arg: "x", + expect: `${color(palette.pass)}x${RESET}`, + }, + { + name: "Hex literal", + arg: "x", + expect: "\\x1b[38;2;255;0;0mx\\x1b[0m", + }, + { + name: "Background", + arg: "x", + expect: `${bgColor(palette.pass)}x${RESET}`, + }, + { + name: "Nested preserves outer", + arg: "xy", + expect: `${color(palette.pass)}${color(palette.fail)}x${RESET}${color(palette.pass)}y${RESET}`, + }, + { + name: "Diff-style", + arg: " + added - removed", + expect: `${bgColor(palette.gutter)} ${color(palette["diff-added"])}+ added${RESET}${bgColor(palette.gutter)} ${color(palette["diff-removed"])}- removed${RESET}${bgColor(palette.gutter)}${RESET}`, + }, + { + name: "Unknown color ignored", + arg: "x", + expect: `x${RESET}`, + }, + { + name: "Nested unknown preserves outer", + arg: "x", + expect: `${color(palette.pass)}\\x1b[1mx${RESET}${color(palette.pass)}${RESET}${color(palette.pass)}${RESET}`, + }, + { + name: "3-char hex expansion", + arg: "x", + expect: "\\x1b[38;2;255;0;0mx\\x1b[0m", + }, + { + name: "Empty string", + arg: "", + expect: "", + }, + ], }, { - name: "Background color", - args: "red", - expect: "\\x1b[41mred\\x1b[0m", + name: "256", + data: { mode: "256" }, + tests: [ + { + name: "Semantic token via 256 cube", + arg: "x", + expect: `${color256(palette.pass)}x${RESET}`, + }, + { + name: "Hex literal red → index 196", + arg: "x", + expect: "\\x1b[38;5;196mx\\x1b[0m", + }, + ], }, { - name: "Light color", - args: "light red", - // expect: "\\x1b[91mlight red\\x1b[0m" - expect: escape(chalk.redBright("light red")), + name: "Strip", + data: { mode: "strip" }, + tests: [ + { + name: "Semantic token stripped", + arg: "x", + expect: `x${RESET}`, + }, + { + name: "Hex literal stripped", + arg: "x", + expect: `x${RESET}`, + }, + { + name: "Colors stripped, modifiers kept", + arg: "x", + expect: `\\x1b[1mx${RESET}\\x1b[1m${RESET}`, + }, + ], }, { - name: "Light background color", - args: "light red", - // expect: "\\x1b[101mlight red\\x1b[0m" - expect: escape(chalk.bgRedBright("light red")), + name: "CSS", + data: { mode: "css" }, + tests: [ + { + name: "Single foreground", + arg: "x", + expect: ["%cx%c", `color: ${palette.pass}`, ""], + }, + { + name: "Nested foreground", + arg: "x", + expect: [ + "%c%cx%c%c", + `color: ${palette.pass}`, + `color: ${palette.fail}`, + `color: ${palette.pass}`, + "", + ], + }, + { + name: "Background plus bold", + arg: "x", + expect: [ + "%c%cx%c%c", + "font-weight: bold", + `font-weight: bold; background: ${palette.pass}`, + "font-weight: bold", + "", + ], + }, + { + name: "Diff-style", + arg: " + added - removed", + expect: [ + "%c %c+ added%c %c- removed%c%c", + `background: ${palette.gutter}`, + `color: ${palette["diff-added"]}; background: ${palette.gutter}`, + `background: ${palette.gutter}`, + `color: ${palette["diff-removed"]}; background: ${palette.gutter}`, + `background: ${palette.gutter}`, + "", + ], + }, + ], }, ], }, { - name: "Strip formatting", + name: "stripFormatting()", run: stripFormatting, tests: [ - { - args: "bold", - expect: "bold", - }, + { arg: "x", expect: "x" }, { name: "Malformed tags", - args: "bold red?", - expect: "bold red?", + arg: "bold x?", + expect: "bold x?", }, ], }, diff --git a/tests/format-diff.js b/tests/format-diff.js index 6cdd060..ddf2b80 100644 --- a/tests/format-diff.js +++ b/tests/format-diff.js @@ -32,31 +32,31 @@ export default { { name: "Inline char diff", args: ["abc", "adc"], - expect: `Got "abc", expected "adc"`, + expect: `Got "abc", expected "adc"`, }, { name: "Inline char diff with unmapped values", args: ["abc", "adc", { actual: "foo", expected: "bar" }], - expect: `Got "abc" ("foo" unmapped), expected "adc" ("bar" unmapped)`, + expect: `Got "abc" ("foo" unmapped), expected "adc" ("bar" unmapped)`, }, { name: "Two-line word diff", args: [`${longPrefix} eta`, `${longPrefix} theta`], - expect: ` Actual: "${longPrefix} eta" - Expected: "${longPrefix} theta"`, + expect: ` Actual: "${longPrefix} eta" + Expected: "${longPrefix} theta"`, }, { name: "Two-line word diff with unmapped values", args: [`${longPrefix} eta`, `${longPrefix} theta`, { actual: "foo", expected: "bar" }], - expect: ` Actual: "${longPrefix} eta" + expect: ` Actual: "${longPrefix} eta" "foo" unmapped - Expected: "${longPrefix} theta" + Expected: "${longPrefix} theta" "bar" unmapped`, }, { name: "Inline multiline string diff", args: ["one\ntwo\nthree", "one\nTWO\nthree"], - expect: `Got "one\\ntwo\\nthree", expected "one\\nTWO\\nthree"`, + expect: `Got "one\\ntwo\\nthree", expected "one\\nTWO\\nthree"`, }, { name: "Elided array hunks", @@ -72,15 +72,15 @@ export default { … 5 matching lines … "line4", "line5", -- \t"X", -+ \t"x", +- \t"X", ++ \t"x", "line7", "line8", … 3 matching lines … "line12", "line13", -- \t"Y", -+ \t"y", +- \t"Y", ++ \t"y", "line15", "line16", ]`, @@ -100,8 +100,8 @@ export default { args: ["foo\nbar\n", "foo\nbaz\n", { actual: "bar", expected: "yolo" }], expect: ` Actual ↔ Expected: foo -- bar -+ baz +- bar ++ baz Actual unmapped: "bar" Expected unmapped: "yolo"`, }, @@ -109,39 +109,39 @@ export default { name: "multiple paired lines", args: ["foo13\nbar42\n", "foo25\nbar47\n"], expect: ` Actual ↔ Expected: -- foo13 -+ foo25 -- bar42 -+ bar47`, +- foo13 ++ foo25 +- bar42 ++ bar47`, }, { name: "noisy swap collapses via cleanup", args: ["fix: button alignment\n", "fix: button padding\n"], expect: ` Actual ↔ Expected: -- fix: button alignment -+ fix: button padding`, +- fix: button alignment ++ fix: button padding`, }, { name: "unequal counts stay plain", args: ["foo\nbar\n", "baz\n"], expect: ` Actual ↔ Expected: -- foo -- bar -+ baz`, +- foo +- bar ++ baz`, }, { name: "added line", args: ["foo\n", "foo\nbar\n"], expect: ` Actual ↔ Expected: foo -+ bar`, ++ bar`, }, { name: "removed line", args: ["foo\nbar\n", "foo\n"], expect: ` Actual ↔ Expected: foo -- bar`, +- bar`, }, ], },