From 35f6196ed4d4354a9a137123e9904a69d0f4e5a2 Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 9 Apr 2026 16:22:21 +0000 Subject: [PATCH 01/11] console.table: escape newlines/tabs in string cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #29082. Before: cells containing \n landed a literal newline in the middle of the row, breaking the table border: $ bun -e 'console.table([{ foo: 123, bar: "Hello\nWorld"}])' ┌───┬─────┬────────────┐ │ │ foo │ bar │ ├───┼─────┼────────────┤ │ 0 │ 123 │ Hello World │ └───┴─────┴────────────┘ After: strings containing \n, \r, or \t are rendered in the quoted JSON-escaped form (matching Node.js), so the row stays on one line: ┌───┬─────┬────────────────┐ │ │ foo │ bar │ ├───┼─────┼────────────────┤ │ 0 │ 123 │ "Hello\nWorld" │ └───┴─────┴────────────────┘ TablePrinter.printRow / getWidthForValue previously set quote_strings = false unconditionally for string cells. Bun's stylistic choice is to print plain strings unquoted — that's fine for ordinary strings, but raw control characters break the fixed-width layout because the cell's visible width no longer matches what the writer emits. Promote to the quoted form only when the cell string contains a layout-breaking control character; plain strings still render without quotes. --- src/jsc/ConsoleObject.zig | 21 ++++- test/regression/issue/29082.test.ts | 116 ++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 test/regression/issue/29082.test.ts diff --git a/src/jsc/ConsoleObject.zig b/src/jsc/ConsoleObject.zig index 2489ea14ea03..5faa2b6951f1 100644 --- a/src/jsc/ConsoleObject.zig +++ b/src/jsc/ConsoleObject.zig @@ -326,6 +326,23 @@ pub const TablePrinter = struct { } }; + /// Whether a string cell value should be rendered in quoted/escaped form. + /// Bun normally prints plain strings in `console.table` cells without + /// surrounding quotes, but that breaks the row layout if the string + /// contains control characters whose rendered width doesn't match the + /// computed cell width (issue #29082). In that case, force the quoted + /// form so the formatter JSON-escapes those characters and the table + /// stays intact. + fn shouldQuoteStringCell(this: *TablePrinter, value: JSValue, tag: ConsoleObject.Formatter.Tag.Result) bun.JSError!bool { + if (!(tag.tag == .String or tag.tag == .StringPossiblyFormatted)) return true; + if (!value.isString()) return false; + var str: bun.String = try bun.String.fromJS(value, this.globalObject); + defer str.deref(); + return str.indexOfAsciiChar('\n') != null or + str.indexOfAsciiChar('\r') != null or + str.indexOfAsciiChar('\t') != null; + } + /// Compute how much horizontal space will take a JSValue when printed fn getWidthForValue(this: *TablePrinter, value: JSValue) bun.JSError!u32 { var width: usize = 0; @@ -339,7 +356,7 @@ pub const TablePrinter = struct { var value_formatter = this.value_formatter; const tag = try ConsoleObject.Formatter.Tag.get(value, this.globalObject); - value_formatter.quote_strings = !(tag.tag == .String or tag.tag == .StringPossiblyFormatted); + value_formatter.quote_strings = try this.shouldQuoteStringCell(value, tag); value_formatter.format( tag, *std.Io.Writer, @@ -486,7 +503,7 @@ pub const TablePrinter = struct { const tag = try ConsoleObject.Formatter.Tag.get(value, this.globalObject); var value_formatter = this.value_formatter; - value_formatter.quote_strings = !(tag.tag == .String or tag.tag == .StringPossiblyFormatted); + value_formatter.quote_strings = try this.shouldQuoteStringCell(value, tag); defer { if (value_formatter.map_node) |node| { diff --git a/test/regression/issue/29082.test.ts b/test/regression/issue/29082.test.ts new file mode 100644 index 000000000000..62a24732fbc9 --- /dev/null +++ b/test/regression/issue/29082.test.ts @@ -0,0 +1,116 @@ +// https://github.com/oven-sh/bun/issues/29082 +// +// `console.table` was writing string cell values unquoted. If the string +// contained `\n`, `\r`, or `\t`, the embedded control character landed in +// the middle of the row and broke the table border. For each such case, +// Bun now promotes the cell to the JSON-escaped (quoted) form — matching +// how inspect() already prints top-level strings — so the table stays +// intact. + +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +async function runTable(code: string): Promise { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.exited, + ]); + expect(exitCode).toBe(0); + return stdout; +} + +// The table border is made of repeating ─ characters. After the fix, every +// `│`-delimited row must contain exactly the same number of `│` separators +// as the header — if any cell leaked an embedded newline, the row count +// would increase. +function assertRectangular(out: string) { + const lines = out.split("\n").filter(l => l.trim().length > 0); + // Collect only lines that are table-interior rows (start with `│`). + const rows = lines.filter(l => l.startsWith("│")); + expect(rows.length).toBeGreaterThan(0); + const expectedBars = rows[0]!.split("│").length; + for (const row of rows) { + expect(row.split("│").length).toBe(expectedBars); + } +} + +test("console.table escapes embedded newlines so the row stays on one line", async () => { + const out = await runTable(`console.table([{ foo: 123, bar: "Hello\\nWorld" }]);`); + assertRectangular(out); + expect(out).toContain(`"Hello\\nWorld"`); + // And importantly, the literal newline must NOT be present inside a cell. + // Count lines between the top border `┌...┐` and the bottom `└...┘`: + // there should be exactly 3 (header row, separator, one data row). + const body = out.split("\n").slice(1, -2); // strip top border + trailing + const dataRows = body.filter(l => l.startsWith("│")); + expect(dataRows.length).toBe(2); // header + single data row +}); + +test("console.table escapes embedded carriage returns", async () => { + const out = await runTable(`console.table([{ bar: "Line1\\rLine2" }]);`); + assertRectangular(out); + expect(out).toContain(`"Line1\\rLine2"`); +}); + +test("console.table escapes embedded tabs", async () => { + const out = await runTable(`console.table([{ bar: "tab\\there" }]);`); + assertRectangular(out); + expect(out).toContain(`"tab\\there"`); +}); + +test("console.table leaves plain strings unquoted", async () => { + const out = await runTable(`console.table([{ foo: 123, bar: "Hello World" }]);`); + assertRectangular(out); + expect(out).toContain("Hello World"); + // Plain strings are NOT promoted to the quoted form. + expect(out).not.toContain(`"Hello World"`); + expect(out).not.toContain(`'Hello World'`); +}); + +test("console.table handles multiple newline cells in the same table", async () => { + const out = await runTable( + `console.table([{ a: 1, b: "a\\nb\\nc" }, { a: 2, b: "plain" }]);`, + ); + assertRectangular(out); + expect(out).toContain(`"a\\nb\\nc"`); + expect(out).toContain("plain"); +}); + +test("console.table escapes newlines in Map values", async () => { + const out = await runTable( + `console.table(new Map([["k1", "v1"], ["k2", "v\\n2"]]));`, + ); + assertRectangular(out); + expect(out).toContain(`"v\\n2"`); +}); + +test("console.table escapes newlines in Set values", async () => { + const out = await runTable(`console.table(new Set(["a", "b\\nc"]));`); + assertRectangular(out); + expect(out).toContain(`"b\\nc"`); +}); + +test("console.table escapes newlines in primitive arrays", async () => { + const out = await runTable(`console.table(["hi", "a\\nb", "foo"]);`); + assertRectangular(out); + expect(out).toContain(`"a\\nb"`); + // Plain entries should stay unquoted. + const rows = out.split("\n").filter(l => l.startsWith("│")); + expect(rows.some(r => r.includes(" hi "))).toBe(true); + expect(rows.some(r => r.includes(" foo "))).toBe(true); +}); + +test("console.table with properties arg respects newline escaping", async () => { + const out = await runTable( + `console.table([{a:1, b:"x\\ny"}, {a:2, b:"normal"}], ["b"]);`, + ); + assertRectangular(out); + expect(out).toContain(`"x\\ny"`); + expect(out).toContain("normal"); +}); From 541de9e48a9fa8c57389d054aaff0976fdd7b6da Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:24:27 +0000 Subject: [PATCH 02/11] [autofix.ci] apply automated fixes --- test/regression/issue/29082.test.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/test/regression/issue/29082.test.ts b/test/regression/issue/29082.test.ts index 62a24732fbc9..62aa3e4adc41 100644 --- a/test/regression/issue/29082.test.ts +++ b/test/regression/issue/29082.test.ts @@ -17,10 +17,7 @@ async function runTable(code: string): Promise { stdout: "pipe", stderr: "pipe", }); - const [stdout, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.exited, - ]); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); expect(exitCode).toBe(0); return stdout; } @@ -74,18 +71,14 @@ test("console.table leaves plain strings unquoted", async () => { }); test("console.table handles multiple newline cells in the same table", async () => { - const out = await runTable( - `console.table([{ a: 1, b: "a\\nb\\nc" }, { a: 2, b: "plain" }]);`, - ); + const out = await runTable(`console.table([{ a: 1, b: "a\\nb\\nc" }, { a: 2, b: "plain" }]);`); assertRectangular(out); expect(out).toContain(`"a\\nb\\nc"`); expect(out).toContain("plain"); }); test("console.table escapes newlines in Map values", async () => { - const out = await runTable( - `console.table(new Map([["k1", "v1"], ["k2", "v\\n2"]]));`, - ); + const out = await runTable(`console.table(new Map([["k1", "v1"], ["k2", "v\\n2"]]));`); assertRectangular(out); expect(out).toContain(`"v\\n2"`); }); @@ -107,9 +100,7 @@ test("console.table escapes newlines in primitive arrays", async () => { }); test("console.table with properties arg respects newline escaping", async () => { - const out = await runTable( - `console.table([{a:1, b:"x\\ny"}, {a:2, b:"normal"}], ["b"]);`, - ); + const out = await runTable(`console.table([{a:1, b:"x\\ny"}, {a:2, b:"normal"}], ["b"]);`); assertRectangular(out); expect(out).toContain(`"x\\ny"`); expect(out).toContain("normal"); From 23c340fa1e2e4fe8934faedfb2a790952fdbb1fb Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 9 Apr 2026 16:36:18 +0000 Subject: [PATCH 03/11] test: address coderabbit feedback on #29082 - trim multi-line bug history to a single issue reference - move exitCode assertion out of runTable so callers check stdout first, matching Bun's test convention --- src/jsc/ConsoleObject.zig | 21 +----- test/regression/issue/29082.test.ts | 110 ++++++++++++++-------------- 2 files changed, 55 insertions(+), 76 deletions(-) diff --git a/src/jsc/ConsoleObject.zig b/src/jsc/ConsoleObject.zig index 5faa2b6951f1..2489ea14ea03 100644 --- a/src/jsc/ConsoleObject.zig +++ b/src/jsc/ConsoleObject.zig @@ -326,23 +326,6 @@ pub const TablePrinter = struct { } }; - /// Whether a string cell value should be rendered in quoted/escaped form. - /// Bun normally prints plain strings in `console.table` cells without - /// surrounding quotes, but that breaks the row layout if the string - /// contains control characters whose rendered width doesn't match the - /// computed cell width (issue #29082). In that case, force the quoted - /// form so the formatter JSON-escapes those characters and the table - /// stays intact. - fn shouldQuoteStringCell(this: *TablePrinter, value: JSValue, tag: ConsoleObject.Formatter.Tag.Result) bun.JSError!bool { - if (!(tag.tag == .String or tag.tag == .StringPossiblyFormatted)) return true; - if (!value.isString()) return false; - var str: bun.String = try bun.String.fromJS(value, this.globalObject); - defer str.deref(); - return str.indexOfAsciiChar('\n') != null or - str.indexOfAsciiChar('\r') != null or - str.indexOfAsciiChar('\t') != null; - } - /// Compute how much horizontal space will take a JSValue when printed fn getWidthForValue(this: *TablePrinter, value: JSValue) bun.JSError!u32 { var width: usize = 0; @@ -356,7 +339,7 @@ pub const TablePrinter = struct { var value_formatter = this.value_formatter; const tag = try ConsoleObject.Formatter.Tag.get(value, this.globalObject); - value_formatter.quote_strings = try this.shouldQuoteStringCell(value, tag); + value_formatter.quote_strings = !(tag.tag == .String or tag.tag == .StringPossiblyFormatted); value_formatter.format( tag, *std.Io.Writer, @@ -503,7 +486,7 @@ pub const TablePrinter = struct { const tag = try ConsoleObject.Formatter.Tag.get(value, this.globalObject); var value_formatter = this.value_formatter; - value_formatter.quote_strings = try this.shouldQuoteStringCell(value, tag); + value_formatter.quote_strings = !(tag.tag == .String or tag.tag == .StringPossiblyFormatted); defer { if (value_formatter.map_node) |node| { diff --git a/test/regression/issue/29082.test.ts b/test/regression/issue/29082.test.ts index 62aa3e4adc41..d823330684f0 100644 --- a/test/regression/issue/29082.test.ts +++ b/test/regression/issue/29082.test.ts @@ -1,16 +1,8 @@ // https://github.com/oven-sh/bun/issues/29082 -// -// `console.table` was writing string cell values unquoted. If the string -// contained `\n`, `\r`, or `\t`, the embedded control character landed in -// the middle of the row and broke the table border. For each such case, -// Bun now promotes the cell to the JSON-escaped (quoted) form — matching -// how inspect() already prints top-level strings — so the table stays -// intact. - import { expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; -async function runTable(code: string): Promise { +async function runTable(code: string): Promise<{ stdout: string; exitCode: number }> { await using proc = Bun.spawn({ cmd: [bunExe(), "-e", code], env: bunEnv, @@ -18,18 +10,16 @@ async function runTable(code: string): Promise { stderr: "pipe", }); const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - expect(exitCode).toBe(0); - return stdout; + return { stdout, exitCode }; } -// The table border is made of repeating ─ characters. After the fix, every -// `│`-delimited row must contain exactly the same number of `│` separators -// as the header — if any cell leaked an embedded newline, the row count -// would increase. +// Every `│`-delimited row must have the same number of separators as the +// header — if any cell leaked an embedded newline, the count would differ. function assertRectangular(out: string) { - const lines = out.split("\n").filter(l => l.trim().length > 0); - // Collect only lines that are table-interior rows (start with `│`). - const rows = lines.filter(l => l.startsWith("│")); + const rows = out + .split("\n") + .filter(l => l.trim().length > 0) + .filter(l => l.startsWith("│")); expect(rows.length).toBeGreaterThan(0); const expectedBars = rows[0]!.split("│").length; for (const row of rows) { @@ -38,70 +28,76 @@ function assertRectangular(out: string) { } test("console.table escapes embedded newlines so the row stays on one line", async () => { - const out = await runTable(`console.table([{ foo: 123, bar: "Hello\\nWorld" }]);`); - assertRectangular(out); - expect(out).toContain(`"Hello\\nWorld"`); - // And importantly, the literal newline must NOT be present inside a cell. - // Count lines between the top border `┌...┐` and the bottom `└...┘`: - // there should be exactly 3 (header row, separator, one data row). - const body = out.split("\n").slice(1, -2); // strip top border + trailing - const dataRows = body.filter(l => l.startsWith("│")); - expect(dataRows.length).toBe(2); // header + single data row + const { stdout, exitCode } = await runTable(`console.table([{ foo: 123, bar: "Hello\\nWorld" }]);`); + assertRectangular(stdout); + expect(stdout).toContain(`"Hello\\nWorld"`); + // One data row only — no extra line from a leaked `\n`. + const dataRows = stdout.split("\n").filter(l => l.startsWith("│")); + expect(dataRows.length).toBe(2); // header + one data row + expect(exitCode).toBe(0); }); test("console.table escapes embedded carriage returns", async () => { - const out = await runTable(`console.table([{ bar: "Line1\\rLine2" }]);`); - assertRectangular(out); - expect(out).toContain(`"Line1\\rLine2"`); + const { stdout, exitCode } = await runTable(`console.table([{ bar: "Line1\\rLine2" }]);`); + assertRectangular(stdout); + expect(stdout).toContain(`"Line1\\rLine2"`); + expect(exitCode).toBe(0); }); test("console.table escapes embedded tabs", async () => { - const out = await runTable(`console.table([{ bar: "tab\\there" }]);`); - assertRectangular(out); - expect(out).toContain(`"tab\\there"`); + const { stdout, exitCode } = await runTable(`console.table([{ bar: "tab\\there" }]);`); + assertRectangular(stdout); + expect(stdout).toContain(`"tab\\there"`); + expect(exitCode).toBe(0); }); test("console.table leaves plain strings unquoted", async () => { - const out = await runTable(`console.table([{ foo: 123, bar: "Hello World" }]);`); - assertRectangular(out); - expect(out).toContain("Hello World"); + const { stdout, exitCode } = await runTable(`console.table([{ foo: 123, bar: "Hello World" }]);`); + assertRectangular(stdout); + expect(stdout).toContain("Hello World"); // Plain strings are NOT promoted to the quoted form. - expect(out).not.toContain(`"Hello World"`); - expect(out).not.toContain(`'Hello World'`); + expect(stdout).not.toContain(`"Hello World"`); + expect(stdout).not.toContain(`'Hello World'`); + expect(exitCode).toBe(0); }); test("console.table handles multiple newline cells in the same table", async () => { - const out = await runTable(`console.table([{ a: 1, b: "a\\nb\\nc" }, { a: 2, b: "plain" }]);`); - assertRectangular(out); - expect(out).toContain(`"a\\nb\\nc"`); - expect(out).toContain("plain"); + const { stdout, exitCode } = await runTable(`console.table([{ a: 1, b: "a\\nb\\nc" }, { a: 2, b: "plain" }]);`); + assertRectangular(stdout); + expect(stdout).toContain(`"a\\nb\\nc"`); + expect(stdout).toContain("plain"); + expect(exitCode).toBe(0); }); test("console.table escapes newlines in Map values", async () => { - const out = await runTable(`console.table(new Map([["k1", "v1"], ["k2", "v\\n2"]]));`); - assertRectangular(out); - expect(out).toContain(`"v\\n2"`); + const { stdout, exitCode } = await runTable(`console.table(new Map([["k1", "v1"], ["k2", "v\\n2"]]));`); + assertRectangular(stdout); + expect(stdout).toContain(`"v\\n2"`); + expect(exitCode).toBe(0); }); test("console.table escapes newlines in Set values", async () => { - const out = await runTable(`console.table(new Set(["a", "b\\nc"]));`); - assertRectangular(out); - expect(out).toContain(`"b\\nc"`); + const { stdout, exitCode } = await runTable(`console.table(new Set(["a", "b\\nc"]));`); + assertRectangular(stdout); + expect(stdout).toContain(`"b\\nc"`); + expect(exitCode).toBe(0); }); test("console.table escapes newlines in primitive arrays", async () => { - const out = await runTable(`console.table(["hi", "a\\nb", "foo"]);`); - assertRectangular(out); - expect(out).toContain(`"a\\nb"`); - // Plain entries should stay unquoted. - const rows = out.split("\n").filter(l => l.startsWith("│")); + const { stdout, exitCode } = await runTable(`console.table(["hi", "a\\nb", "foo"]);`); + assertRectangular(stdout); + expect(stdout).toContain(`"a\\nb"`); + // Plain entries stay unquoted. + const rows = stdout.split("\n").filter(l => l.startsWith("│")); expect(rows.some(r => r.includes(" hi "))).toBe(true); expect(rows.some(r => r.includes(" foo "))).toBe(true); + expect(exitCode).toBe(0); }); test("console.table with properties arg respects newline escaping", async () => { - const out = await runTable(`console.table([{a:1, b:"x\\ny"}, {a:2, b:"normal"}], ["b"]);`); - assertRectangular(out); - expect(out).toContain(`"x\\ny"`); - expect(out).toContain("normal"); + const { stdout, exitCode } = await runTable(`console.table([{a:1, b:"x\\ny"}, {a:2, b:"normal"}], ["b"]);`); + assertRectangular(stdout); + expect(stdout).toContain(`"x\\ny"`); + expect(stdout).toContain("normal"); + expect(exitCode).toBe(0); }); From 53fb0648a8962c1cc3837343df2cc0a6c947c934 Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 9 Apr 2026 17:56:02 +0000 Subject: [PATCH 04/11] console.table: restore fix + broaden to full C0 control range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit accidentally reverted src/bun.js/ConsoleObject.zig while cleaning up the test file — restore shouldQuoteStringCell and both of its call sites, and broaden the check from just \n/\r/\t to every C0 control character (0x00–0x1F). \v and \f move the cursor in real terminals the same way \n does; \0 and the rest are counted as zero visible width but emitted as literal bytes, which still mismatches the column width calculation. Test changes: - drain stderr in runTable so a future caller emitting >64KB of stderr doesn't deadlock on the pipe buffer - describe.concurrent so the 10 subprocess spawns run in parallel - drop the tautological dataRows.length==2 assertion (it was true in both the fixed and unfixed states — a leaked \n splits one row into two lines but only the first starts with the │ border) - add a coverage case for \v, \f, and NUL --- src/jsc/ConsoleObject.zig | 34 ++++++- test/regression/issue/29082.test.ts | 152 ++++++++++++++++------------ 2 files changed, 117 insertions(+), 69 deletions(-) diff --git a/src/jsc/ConsoleObject.zig b/src/jsc/ConsoleObject.zig index 2489ea14ea03..ad921a9a6a3d 100644 --- a/src/jsc/ConsoleObject.zig +++ b/src/jsc/ConsoleObject.zig @@ -326,6 +326,36 @@ pub const TablePrinter = struct { } }; + /// Whether a string cell value should be rendered in quoted/escaped form. + /// Bun normally prints plain strings in `console.table` cells without + /// surrounding quotes, but that breaks the row layout if the string + /// contains a C0 control character (0x00–0x1F). \n and \r move the + /// cursor out of the cell entirely; \v and \f can move the cursor + /// down; \t expands to a terminal-dependent width; other C0 chars + /// are emitted as zero bytes that the width calculator counts as + /// zero but the terminal may interpret (issue #29082). Promoting the + /// cell to the quoted form has `writeJSONString` escape the whole + /// string so the table stays rectangular. + /// + /// 0x7F (DEL) is intentionally NOT included: both width calculation + /// and rendering agree it's zero width, so it doesn't break layout. + fn shouldQuoteStringCell(this: *TablePrinter, value: JSValue, tag: ConsoleObject.Formatter.Tag.Result) bun.JSError!bool { + if (!(tag.tag == .String or tag.tag == .StringPossiblyFormatted)) return true; + if (!value.isString()) return false; + var str: bun.String = try bun.String.fromJS(value, this.globalObject); + defer str.deref(); + if (str.isUTF16()) { + for (str.utf16()) |c| { + if (c < 0x20) return true; + } + } else { + for (str.byteSlice()) |b| { + if (b < 0x20) return true; + } + } + return false; + } + /// Compute how much horizontal space will take a JSValue when printed fn getWidthForValue(this: *TablePrinter, value: JSValue) bun.JSError!u32 { var width: usize = 0; @@ -339,7 +369,7 @@ pub const TablePrinter = struct { var value_formatter = this.value_formatter; const tag = try ConsoleObject.Formatter.Tag.get(value, this.globalObject); - value_formatter.quote_strings = !(tag.tag == .String or tag.tag == .StringPossiblyFormatted); + value_formatter.quote_strings = try this.shouldQuoteStringCell(value, tag); value_formatter.format( tag, *std.Io.Writer, @@ -486,7 +516,7 @@ pub const TablePrinter = struct { const tag = try ConsoleObject.Formatter.Tag.get(value, this.globalObject); var value_formatter = this.value_formatter; - value_formatter.quote_strings = !(tag.tag == .String or tag.tag == .StringPossiblyFormatted); + value_formatter.quote_strings = try this.shouldQuoteStringCell(value, tag); defer { if (value_formatter.map_node) |node| { diff --git a/test/regression/issue/29082.test.ts b/test/regression/issue/29082.test.ts index d823330684f0..2a6a239833ea 100644 --- a/test/regression/issue/29082.test.ts +++ b/test/regression/issue/29082.test.ts @@ -1,5 +1,5 @@ // https://github.com/oven-sh/bun/issues/29082 -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; async function runTable(code: string): Promise<{ stdout: string; exitCode: number }> { @@ -9,7 +9,9 @@ async function runTable(code: string): Promise<{ stdout: string; exitCode: numbe stdout: "pipe", stderr: "pipe", }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + // Drain stderr even though we don't inspect it — leaving it buffered would + // deadlock once the child wrote >~64KB to stderr. + const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); return { stdout, exitCode }; } @@ -27,77 +29,93 @@ function assertRectangular(out: string) { } } -test("console.table escapes embedded newlines so the row stays on one line", async () => { - const { stdout, exitCode } = await runTable(`console.table([{ foo: 123, bar: "Hello\\nWorld" }]);`); - assertRectangular(stdout); - expect(stdout).toContain(`"Hello\\nWorld"`); - // One data row only — no extra line from a leaked `\n`. - const dataRows = stdout.split("\n").filter(l => l.startsWith("│")); - expect(dataRows.length).toBe(2); // header + one data row - expect(exitCode).toBe(0); -}); +describe.concurrent("console.table quotes cells containing control characters", () => { + test("newline keeps the row on a single line", async () => { + const { stdout, exitCode } = await runTable(`console.table([{ foo: 123, bar: "Hello\\nWorld" }]);`); + assertRectangular(stdout); + expect(stdout).toContain(`"Hello\\nWorld"`); + // No raw literal newline mid-cell. + expect(stdout).not.toMatch(/│[^│\n]*Hello\n/); + expect(exitCode).toBe(0); + }); -test("console.table escapes embedded carriage returns", async () => { - const { stdout, exitCode } = await runTable(`console.table([{ bar: "Line1\\rLine2" }]);`); - assertRectangular(stdout); - expect(stdout).toContain(`"Line1\\rLine2"`); - expect(exitCode).toBe(0); -}); + test("carriage return", async () => { + const { stdout, exitCode } = await runTable(`console.table([{ bar: "Line1\\rLine2" }]);`); + assertRectangular(stdout); + expect(stdout).toContain(`"Line1\\rLine2"`); + expect(exitCode).toBe(0); + }); -test("console.table escapes embedded tabs", async () => { - const { stdout, exitCode } = await runTable(`console.table([{ bar: "tab\\there" }]);`); - assertRectangular(stdout); - expect(stdout).toContain(`"tab\\there"`); - expect(exitCode).toBe(0); -}); + test("tab", async () => { + const { stdout, exitCode } = await runTable(`console.table([{ bar: "tab\\there" }]);`); + assertRectangular(stdout); + expect(stdout).toContain(`"tab\\there"`); + expect(exitCode).toBe(0); + }); -test("console.table leaves plain strings unquoted", async () => { - const { stdout, exitCode } = await runTable(`console.table([{ foo: 123, bar: "Hello World" }]);`); - assertRectangular(stdout); - expect(stdout).toContain("Hello World"); - // Plain strings are NOT promoted to the quoted form. - expect(stdout).not.toContain(`"Hello World"`); - expect(stdout).not.toContain(`'Hello World'`); - expect(exitCode).toBe(0); -}); + test("other C0 control chars (vertical tab, form feed, NUL)", async () => { + // \v (0x0B), \f (0x0C), and \0 (NUL) also move the cursor or mismatch + // the visible-width calculation — the fix covers the full C0 range + // (0x00–0x1F), not just \n/\r/\t. + const { stdout, exitCode } = await runTable(`console.table([{ bar: "a\\vb\\fc\\x00d" }]);`); + assertRectangular(stdout); + // writeJSONString escapes each C0 char — none survive raw. + expect(stdout).not.toMatch(/[\x00-\x08\x0B\x0C\x0E-\x1F]/); + expect(exitCode).toBe(0); + }); -test("console.table handles multiple newline cells in the same table", async () => { - const { stdout, exitCode } = await runTable(`console.table([{ a: 1, b: "a\\nb\\nc" }, { a: 2, b: "plain" }]);`); - assertRectangular(stdout); - expect(stdout).toContain(`"a\\nb\\nc"`); - expect(stdout).toContain("plain"); - expect(exitCode).toBe(0); -}); + test("plain strings stay unquoted", async () => { + const { stdout, exitCode } = await runTable(`console.table([{ foo: 123, bar: "Hello World" }]);`); + assertRectangular(stdout); + expect(stdout).toContain("Hello World"); + // Plain strings are NOT promoted to the quoted form. + expect(stdout).not.toContain(`"Hello World"`); + expect(stdout).not.toContain(`'Hello World'`); + expect(exitCode).toBe(0); + }); -test("console.table escapes newlines in Map values", async () => { - const { stdout, exitCode } = await runTable(`console.table(new Map([["k1", "v1"], ["k2", "v\\n2"]]));`); - assertRectangular(stdout); - expect(stdout).toContain(`"v\\n2"`); - expect(exitCode).toBe(0); -}); + test("multiple newline cells in the same table", async () => { + const { stdout, exitCode } = await runTable( + `console.table([{ a: 1, b: "a\\nb\\nc" }, { a: 2, b: "plain" }]);`, + ); + assertRectangular(stdout); + expect(stdout).toContain(`"a\\nb\\nc"`); + expect(stdout).toContain("plain"); + expect(exitCode).toBe(0); + }); -test("console.table escapes newlines in Set values", async () => { - const { stdout, exitCode } = await runTable(`console.table(new Set(["a", "b\\nc"]));`); - assertRectangular(stdout); - expect(stdout).toContain(`"b\\nc"`); - expect(exitCode).toBe(0); -}); + test("newlines in Map values", async () => { + const { stdout, exitCode } = await runTable(`console.table(new Map([["k1", "v1"], ["k2", "v\\n2"]]));`); + assertRectangular(stdout); + expect(stdout).toContain(`"v\\n2"`); + expect(exitCode).toBe(0); + }); -test("console.table escapes newlines in primitive arrays", async () => { - const { stdout, exitCode } = await runTable(`console.table(["hi", "a\\nb", "foo"]);`); - assertRectangular(stdout); - expect(stdout).toContain(`"a\\nb"`); - // Plain entries stay unquoted. - const rows = stdout.split("\n").filter(l => l.startsWith("│")); - expect(rows.some(r => r.includes(" hi "))).toBe(true); - expect(rows.some(r => r.includes(" foo "))).toBe(true); - expect(exitCode).toBe(0); -}); + test("newlines in Set values", async () => { + const { stdout, exitCode } = await runTable(`console.table(new Set(["a", "b\\nc"]));`); + assertRectangular(stdout); + expect(stdout).toContain(`"b\\nc"`); + expect(exitCode).toBe(0); + }); + + test("newlines in primitive arrays", async () => { + const { stdout, exitCode } = await runTable(`console.table(["hi", "a\\nb", "foo"]);`); + assertRectangular(stdout); + expect(stdout).toContain(`"a\\nb"`); + // Plain entries stay unquoted. + const rows = stdout.split("\n").filter(l => l.startsWith("│")); + expect(rows.some(r => r.includes(" hi "))).toBe(true); + expect(rows.some(r => r.includes(" foo "))).toBe(true); + expect(exitCode).toBe(0); + }); -test("console.table with properties arg respects newline escaping", async () => { - const { stdout, exitCode } = await runTable(`console.table([{a:1, b:"x\\ny"}, {a:2, b:"normal"}], ["b"]);`); - assertRectangular(stdout); - expect(stdout).toContain(`"x\\ny"`); - expect(stdout).toContain("normal"); - expect(exitCode).toBe(0); + test("properties arg respects newline escaping", async () => { + const { stdout, exitCode } = await runTable( + `console.table([{a:1, b:"x\\ny"}, {a:2, b:"normal"}], ["b"]);`, + ); + assertRectangular(stdout); + expect(stdout).toContain(`"x\\ny"`); + expect(stdout).toContain("normal"); + expect(exitCode).toBe(0); + }); }); From 1a86dc9a6de09b3e300b771e6501626fe9cfcc59 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:57:51 +0000 Subject: [PATCH 05/11] [autofix.ci] apply automated fixes --- test/regression/issue/29082.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/regression/issue/29082.test.ts b/test/regression/issue/29082.test.ts index 2a6a239833ea..d85e9df079c3 100644 --- a/test/regression/issue/29082.test.ts +++ b/test/regression/issue/29082.test.ts @@ -75,9 +75,7 @@ describe.concurrent("console.table quotes cells containing control characters", }); test("multiple newline cells in the same table", async () => { - const { stdout, exitCode } = await runTable( - `console.table([{ a: 1, b: "a\\nb\\nc" }, { a: 2, b: "plain" }]);`, - ); + const { stdout, exitCode } = await runTable(`console.table([{ a: 1, b: "a\\nb\\nc" }, { a: 2, b: "plain" }]);`); assertRectangular(stdout); expect(stdout).toContain(`"a\\nb\\nc"`); expect(stdout).toContain("plain"); @@ -110,9 +108,7 @@ describe.concurrent("console.table quotes cells containing control characters", }); test("properties arg respects newline escaping", async () => { - const { stdout, exitCode } = await runTable( - `console.table([{a:1, b:"x\\ny"}, {a:2, b:"normal"}], ["b"]);`, - ); + const { stdout, exitCode } = await runTable(`console.table([{a:1, b:"x\\ny"}, {a:2, b:"normal"}], ["b"]);`); assertRectangular(stdout); expect(stdout).toContain(`"x\\ny"`); expect(stdout).toContain("normal"); From 4c5020a6fe203965b46017f22eb03ee9f724503e Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 9 Apr 2026 18:39:29 +0000 Subject: [PATCH 06/11] console.table: exclude ESC from control-char check, dedupe work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from review: 1. Exclude 0x1B (ESC) from the C0 range check. Before: chalk.red('FAIL') output was JSON-escaped as '\u001b[31mFAIL\u001b[0m', destroying terminal colors and massively oversizing the column. ANSI sequences are already stripped from visible-width measurement by VisibleCharacterCounter (exclude_ansi_colors), so raw ESC and the width calculation agree — same rationale as 0x7F. Add a regression test for chalk-style output. 2. Avoid calling shouldQuoteStringCell twice per cell in printRow. The helper does bun.String.fromJS + a byte scan, and was running once inside getWidthForValue and again directly. Split into getWidthForValueWithTag (takes the already-decided flag) with a thin wrapper that keeps the old signature for updateColumnsForRow. 3. Add a positive toContain assertion to the C0 control-char test — it previously only had a negative not.toMatch which would pass even if the cell value were silently dropped. --- src/jsc/ConsoleObject.zig | 62 ++++++++++++++++++++--------- test/regression/issue/29082.test.ts | 25 ++++++++++-- 2 files changed, 66 insertions(+), 21 deletions(-) diff --git a/src/jsc/ConsoleObject.zig b/src/jsc/ConsoleObject.zig index ad921a9a6a3d..55e51a898d97 100644 --- a/src/jsc/ConsoleObject.zig +++ b/src/jsc/ConsoleObject.zig @@ -329,16 +329,21 @@ pub const TablePrinter = struct { /// Whether a string cell value should be rendered in quoted/escaped form. /// Bun normally prints plain strings in `console.table` cells without /// surrounding quotes, but that breaks the row layout if the string - /// contains a C0 control character (0x00–0x1F). \n and \r move the - /// cursor out of the cell entirely; \v and \f can move the cursor - /// down; \t expands to a terminal-dependent width; other C0 chars - /// are emitted as zero bytes that the width calculator counts as - /// zero but the terminal may interpret (issue #29082). Promoting the - /// cell to the quoted form has `writeJSONString` escape the whole - /// string so the table stays rectangular. + /// contains a C0 control character whose rendered width doesn't match + /// what the writer emits: \n and \r move the cursor out of the cell + /// entirely, \v and \f move it down, \t expands to a terminal-dependent + /// width, and other C0 chars are counted as zero width but emitted as + /// literal bytes (issue #29082). Promoting the cell to the quoted form + /// has `writeJSONString` escape the whole string so the table stays + /// rectangular. /// - /// 0x7F (DEL) is intentionally NOT included: both width calculation - /// and rendering agree it's zero width, so it doesn't break layout. + /// Intentionally NOT included: + /// - 0x1B (ESC): starts ANSI color sequences. `VisibleCharacterCounter` + /// already strips those from the width calculation, and the formatter + /// emits the bytes raw — both agree, so layout is preserved and + /// colors survive. Quoting them would destroy chalk/picocolors output. + /// - 0x7F (DEL): both sides count it as zero width, so it doesn't + /// break layout either. fn shouldQuoteStringCell(this: *TablePrinter, value: JSValue, tag: ConsoleObject.Formatter.Tag.Result) bun.JSError!bool { if (!(tag.tag == .String or tag.tag == .StringPossiblyFormatted)) return true; if (!value.isString()) return false; @@ -346,18 +351,25 @@ pub const TablePrinter = struct { defer str.deref(); if (str.isUTF16()) { for (str.utf16()) |c| { - if (c < 0x20) return true; + if (c < 0x20 and c != 0x1B) return true; } } else { for (str.byteSlice()) |b| { - if (b < 0x20) return true; + if (b < 0x20 and b != 0x1B) return true; } } return false; } - /// Compute how much horizontal space will take a JSValue when printed - fn getWidthForValue(this: *TablePrinter, value: JSValue) bun.JSError!u32 { + /// Compute how much horizontal space a JSValue will take when printed, + /// using the already-decided `quote_strings` flag so both width and + /// render agree without repeating the `shouldQuoteStringCell` scan. + fn getWidthForValueWithTag( + this: *TablePrinter, + value: JSValue, + tag: ConsoleObject.Formatter.Tag.Result, + quote_strings: bool, + ) bun.JSError!u32 { var width: usize = 0; var old_writer = VisibleCharacterCounter.Writer{ .context = .{ @@ -368,8 +380,7 @@ pub const TablePrinter = struct { var adapted_writer = old_writer.adaptToNewApi(&discard_buf); var value_formatter = this.value_formatter; - const tag = try ConsoleObject.Formatter.Tag.get(value, this.globalObject); - value_formatter.quote_strings = try this.shouldQuoteStringCell(value, tag); + value_formatter.quote_strings = quote_strings; value_formatter.format( tag, *std.Io.Writer, @@ -386,6 +397,16 @@ pub const TablePrinter = struct { return @truncate(width); } + /// Compute how much horizontal space will take a JSValue when printed. + /// Resolves the tag and `quote_strings` flag internally — callers that + /// also need to render the value should use `getWidthForValueWithTag` + /// to avoid computing them twice. + fn getWidthForValue(this: *TablePrinter, value: JSValue) bun.JSError!u32 { + const tag = try ConsoleObject.Formatter.Tag.get(value, this.globalObject); + const quote_strings = try this.shouldQuoteStringCell(value, tag); + return this.getWidthForValueWithTag(value, tag, quote_strings); + } + /// Update the sizes of the columns for the values of a given row, and create any additional columns as needed fn updateColumnsForRow(this: *TablePrinter, columns: *std.array_list.Managed(Column), row_key: RowKey, row_value: JSValue) bun.JSError!void { // update size of "(index)" column @@ -510,13 +531,18 @@ pub const TablePrinter = struct { if (value == .zero) { try writer.splatByteAll(' ', col.width + (PADDING * 2)); } else { - const len: u32 = try this.getWidthForValue(value); + // Resolve tag + quote_strings once per cell and reuse for + // both the width calculation and the actual render — the + // `bun.String.fromJS` + byte scan inside `shouldQuoteStringCell` + // is non-trivial and shouldn't run twice. + const tag = try ConsoleObject.Formatter.Tag.get(value, this.globalObject); + const quote_strings = try this.shouldQuoteStringCell(value, tag); + const len: u32 = try this.getWidthForValueWithTag(value, tag, quote_strings); const needed = col.width -| len; try writer.splatByteAll(' ', PADDING); - const tag = try ConsoleObject.Formatter.Tag.get(value, this.globalObject); var value_formatter = this.value_formatter; - value_formatter.quote_strings = try this.shouldQuoteStringCell(value, tag); + value_formatter.quote_strings = quote_strings; defer { if (value_formatter.map_node) |node| { diff --git a/test/regression/issue/29082.test.ts b/test/regression/issue/29082.test.ts index d85e9df079c3..24847c86f2cd 100644 --- a/test/regression/issue/29082.test.ts +++ b/test/regression/issue/29082.test.ts @@ -56,11 +56,30 @@ describe.concurrent("console.table quotes cells containing control characters", test("other C0 control chars (vertical tab, form feed, NUL)", async () => { // \v (0x0B), \f (0x0C), and \0 (NUL) also move the cursor or mismatch // the visible-width calculation — the fix covers the full C0 range - // (0x00–0x1F), not just \n/\r/\t. + // (0x00–0x1F except ESC), not just \n/\r/\t. const { stdout, exitCode } = await runTable(`console.table([{ bar: "a\\vb\\fc\\x00d" }]);`); assertRectangular(stdout); - // writeJSONString escapes each C0 char — none survive raw. - expect(stdout).not.toMatch(/[\x00-\x08\x0B\x0C\x0E-\x1F]/); + // Positive: cell rendered in its JSON-escaped form — \v/\f as short + // escapes, NUL as \u0000. + expect(stdout).toContain(`"a\\vb\\fc\\u0000d"`); + // Negative: no C0 char survives raw (ESC 0x1B excluded — see ANSI test). + expect(stdout).not.toMatch(/[\x00-\x08\x0B\x0C\x0E-\x1A\x1C-\x1F]/); + expect(exitCode).toBe(0); + }); + + test("ANSI escape sequences (ESC) pass through unescaped so colors survive", async () => { + // 0x1B is the first byte of every ANSI color sequence. VisibleCharacterCounter + // already strips ANSI from the width calculation, so quoting these strings + // would destroy chalk/picocolors output without fixing any layout bug. + const { stdout, exitCode } = await runTable( + `console.table([{ status: "\\x1b[31mFAIL\\x1b[0m" }, { status: "\\x1b[32mOK\\x1b[0m" }]);`, + ); + assertRectangular(stdout); + // Cells contain the raw ESC bytes, NOT the JSON-escaped form. + expect(stdout).toContain("\x1b[31mFAIL\x1b[0m"); + expect(stdout).toContain("\x1b[32mOK\x1b[0m"); + expect(stdout).not.toContain("\\u001b"); + expect(stdout).not.toContain("\\u001B"); expect(exitCode).toBe(0); }); From b2ea2575a122c99311463929daf7d515bff17ff3 Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 9 Apr 2026 19:14:59 +0000 Subject: [PATCH 07/11] console.table: also escape control chars in the index column Addresses review note about the index column (column 0) using the old unprotected path: width was measured via visibleWidthExcludeANSIColors (counts \n as zero width) and output via writer.print({f}), which emits raw bytes. console.table({ ['a\nb']: 1 }) still produced a literal newline in the index column after the initial fix, breaking the row border in exactly the same way as the original #29082 bug did for data cells. Extract the shared C0 control-char scan into stringHasLayoutBreakingControlChar (bun.String) and reuse it in three new helpers: - rowKeyWidth: width of a RowKey, accounting for JSON-quoted form - jsonQuotedStringWidth: width of a bun.String when rendered via writeJSONString, measured through VisibleCharacterCounter so it matches the cell-width measurement path - writeRowKey: render a RowKey, calling writeJSONString for string keys with layout-breaking chars and falling back to raw print otherwise updateColumnsForRow and printRow now route the index column through these helpers. Add a regression test for the object-key case. --- src/jsc/ConsoleObject.zig | 84 ++++++++++++++++++++++++----- test/regression/issue/29082.test.ts | 12 +++++ 2 files changed, 84 insertions(+), 12 deletions(-) diff --git a/src/jsc/ConsoleObject.zig b/src/jsc/ConsoleObject.zig index 55e51a898d97..24f7ce26f3e8 100644 --- a/src/jsc/ConsoleObject.zig +++ b/src/jsc/ConsoleObject.zig @@ -349,6 +349,13 @@ pub const TablePrinter = struct { if (!value.isString()) return false; var str: bun.String = try bun.String.fromJS(value, this.globalObject); defer str.deref(); + return stringHasLayoutBreakingControlChar(str); + } + + /// Same as `shouldQuoteStringCell` but operates directly on a + /// `bun.String` — used for the index column, whose row key is a + /// pre-resolved string rather than a `JSValue`. + fn stringHasLayoutBreakingControlChar(str: bun.String) bool { if (str.isUTF16()) { for (str.utf16()) |c| { if (c < 0x20 and c != 0x1B) return true; @@ -407,13 +414,72 @@ pub const TablePrinter = struct { return this.getWidthForValueWithTag(value, tag, quote_strings); } + /// Width a `RowKey` will take in the index column. Quoted row keys + /// (string keys containing layout-breaking control chars) account for + /// the JSON-escaped rendering so width matches what `writeRowKey` + /// emits. + fn rowKeyWidth(row_key: RowKey) u32 { + return switch (row_key) { + .str => |value| blk: { + if (stringHasLayoutBreakingControlChar(value)) { + break :blk jsonQuotedStringWidth(value); + } + break :blk @intCast(value.visibleWidthExcludeANSIColors(false)); + }, + .num => |value| @truncate(bun.fmt.fastDigitCount(value)), + }; + } + + /// Compute the width of a `bun.String` when rendered via + /// `writeJSONString` (i.e. surrounded by `"`, with C0 control chars + /// JSON-escaped). Measured by feeding the formatted bytes through + /// `VisibleCharacterCounter`, matching how `getWidthForValueWithTag` + /// measures data-cell widths. + fn jsonQuotedStringWidth(str: bun.String) u32 { + var width: usize = 0; + var old_writer = VisibleCharacterCounter.Writer{ .context = .{ .width = &width } }; + var discard_buf: [512]u8 = undefined; + var adapted_writer = old_writer.adaptToNewApi(&discard_buf); + const w = &adapted_writer.new_interface; + if (str.is8Bit()) { + JSPrinter.writeJSONString(str.byteSlice(), *std.Io.Writer, w, .latin1) catch {}; + } else { + // UTF-16 → writeJSONString reads `[]const u8` but accepts an + // `.utf16` encoding and reinterprets the bytes as u16. + const u16_slice = str.utf16(); + const byte_ptr = @as([*]const u8, @ptrCast(u16_slice.ptr)); + JSPrinter.writeJSONString(byte_ptr[0 .. u16_slice.len * 2], *std.Io.Writer, w, .utf16) catch {}; + } + w.flush() catch {}; + return @truncate(width); + } + + /// Render a `RowKey` into the index column, quoting + JSON-escaping + /// string keys that contain layout-breaking control chars (mirrors + /// `shouldQuoteStringCell` for the data columns). + fn writeRowKey(comptime Writer: type, writer: Writer, row_key: RowKey) !void { + switch (row_key) { + .str => |value| { + if (stringHasLayoutBreakingControlChar(value)) { + if (value.is8Bit()) { + try JSPrinter.writeJSONString(value.byteSlice(), Writer, writer, .latin1); + } else { + const u16_slice = value.utf16(); + const byte_ptr = @as([*]const u8, @ptrCast(u16_slice.ptr)); + try JSPrinter.writeJSONString(byte_ptr[0 .. u16_slice.len * 2], Writer, writer, .utf16); + } + } else { + try writer.print("{f}", .{value}); + } + }, + .num => |value| try writer.print("{d}", .{value}), + } + } + /// Update the sizes of the columns for the values of a given row, and create any additional columns as needed fn updateColumnsForRow(this: *TablePrinter, columns: *std.array_list.Managed(Column), row_key: RowKey, row_value: JSValue) bun.JSError!void { // update size of "(index)" column - const row_key_len: u32 = switch (row_key) { - .str => |value| @intCast(value.visibleWidthExcludeANSIColors(false)), - .num => |value| @truncate(bun.fmt.fastDigitCount(value)), - }; + const row_key_len: u32 = rowKeyWidth(row_key); columns.items[0].width = @max(columns.items[0].width, row_key_len); // special handling for Map: column with idx=1 is "Keys" @@ -495,18 +561,12 @@ pub const TablePrinter = struct { ) !void { try writer.writeAll("│"); { - const len: u32 = switch (row_key) { - .str => |value| @truncate(value.visibleWidthExcludeANSIColors(false)), - .num => |value| @truncate(bun.fmt.fastDigitCount(value)), - }; + const len: u32 = rowKeyWidth(row_key); const needed = columns.items[0].width -| len; // Right-align the number column try writer.splatByteAll(' ', needed + PADDING); - switch (row_key) { - .str => |value| try writer.print("{f}", .{value}), - .num => |value| try writer.print("{d}", .{value}), - } + try writeRowKey(Writer, writer, row_key); try writer.splatByteAll(' ', PADDING); } diff --git a/test/regression/issue/29082.test.ts b/test/regression/issue/29082.test.ts index 24847c86f2cd..f1b74727a157 100644 --- a/test/regression/issue/29082.test.ts +++ b/test/regression/issue/29082.test.ts @@ -133,4 +133,16 @@ describe.concurrent("console.table quotes cells containing control characters", expect(stdout).toContain("normal"); expect(exitCode).toBe(0); }); + + test("object property keys with newlines are escaped in the index column", async () => { + // When `console.table(obj)` is called with a plain object, the keys + // populate the index column. A key containing \n used to emit a + // literal newline in the index column and break the row layout the + // same way data cells did before the fix. + const { stdout, exitCode } = await runTable(`console.table({ ["a\\nb"]: 1, normal: 2 });`); + assertRectangular(stdout); + expect(stdout).toContain(`"a\\nb"`); + expect(stdout).toContain("normal"); + expect(exitCode).toBe(0); + }); }); From 49b78a4dc2256a5a68d63c5f47b7619bc409d70c Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 9 Apr 2026 19:44:10 +0000 Subject: [PATCH 08/11] console.table: dedupe stringHasLayoutBreakingControlChar in row-key path Same pattern as the data-cell dedup from the previous commit: printRow was calling rowKeyWidth + writeRowKey, each of which independently scanned the bun.String for layout-breaking control chars. Split into rowKeyWithQuote / writeRowKeyWithQuote variants that take the flag from the caller, plus thin wrappers that resolve it internally for the first-pass width computation in updateColumnsForRow. Also extracted the UTF-8 / UTF-16 writeJSONString dispatch into a shared writeQuotedBunString helper since both jsonQuotedStringWidth and writeRowKeyWithQuote need it. --- src/jsc/ConsoleObject.zig | 76 ++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/src/jsc/ConsoleObject.zig b/src/jsc/ConsoleObject.zig index 24f7ce26f3e8..e7e4a4a64693 100644 --- a/src/jsc/ConsoleObject.zig +++ b/src/jsc/ConsoleObject.zig @@ -414,22 +414,33 @@ pub const TablePrinter = struct { return this.getWidthForValueWithTag(value, tag, quote_strings); } - /// Width a `RowKey` will take in the index column. Quoted row keys - /// (string keys containing layout-breaking control chars) account for - /// the JSON-escaped rendering so width matches what `writeRowKey` - /// emits. - fn rowKeyWidth(row_key: RowKey) u32 { + /// Width a `RowKey` will take in the index column when rendered. + /// `quote` is the already-decided flag from + /// `stringHasLayoutBreakingControlChar` — only meaningful for the + /// `.str` variant — so callers that also need to render the key + /// scan the string only once. Mirrors the `getWidthForValueWithTag` + /// pattern used for data cells. + fn rowKeyWidthWithQuote(row_key: RowKey, quote: bool) u32 { return switch (row_key) { - .str => |value| blk: { - if (stringHasLayoutBreakingControlChar(value)) { - break :blk jsonQuotedStringWidth(value); - } - break :blk @intCast(value.visibleWidthExcludeANSIColors(false)); - }, + .str => |value| if (quote) + jsonQuotedStringWidth(value) + else + @intCast(value.visibleWidthExcludeANSIColors(false)), .num => |value| @truncate(bun.fmt.fastDigitCount(value)), }; } + /// Thin wrapper for callers that only need the width (e.g. the + /// first pass in `updateColumnsForRow`) — resolves `quote` + /// internally. + fn rowKeyWidth(row_key: RowKey) u32 { + const quote = switch (row_key) { + .str => |value| stringHasLayoutBreakingControlChar(value), + .num => false, + }; + return rowKeyWidthWithQuote(row_key, quote); + } + /// Compute the width of a `bun.String` when rendered via /// `writeJSONString` (i.e. surrounded by `"`, with C0 control chars /// JSON-escaped). Measured by feeding the formatted bytes through @@ -441,33 +452,35 @@ pub const TablePrinter = struct { var discard_buf: [512]u8 = undefined; var adapted_writer = old_writer.adaptToNewApi(&discard_buf); const w = &adapted_writer.new_interface; + writeQuotedBunString(*std.Io.Writer, w, str) catch {}; + w.flush() catch {}; + return @truncate(width); + } + + /// Render a `bun.String` as a JSON-quoted + escaped literal — picks + /// the right `writeJSONString` encoding branch for 8-bit vs UTF-16. + fn writeQuotedBunString(comptime Writer: type, writer: Writer, str: bun.String) !void { if (str.is8Bit()) { - JSPrinter.writeJSONString(str.byteSlice(), *std.Io.Writer, w, .latin1) catch {}; + try JSPrinter.writeJSONString(str.byteSlice(), Writer, writer, .latin1); } else { // UTF-16 → writeJSONString reads `[]const u8` but accepts an // `.utf16` encoding and reinterprets the bytes as u16. const u16_slice = str.utf16(); const byte_ptr = @as([*]const u8, @ptrCast(u16_slice.ptr)); - JSPrinter.writeJSONString(byte_ptr[0 .. u16_slice.len * 2], *std.Io.Writer, w, .utf16) catch {}; + try JSPrinter.writeJSONString(byte_ptr[0 .. u16_slice.len * 2], Writer, writer, .utf16); } - w.flush() catch {}; - return @truncate(width); } /// Render a `RowKey` into the index column, quoting + JSON-escaping - /// string keys that contain layout-breaking control chars (mirrors - /// `shouldQuoteStringCell` for the data columns). - fn writeRowKey(comptime Writer: type, writer: Writer, row_key: RowKey) !void { + /// string keys that contain layout-breaking control chars. `quote` + /// is the already-decided flag from the width computation so this + /// doesn't re-scan the string — mirrors the data-cell dedup from + /// `getWidthForValueWithTag`. + fn writeRowKeyWithQuote(comptime Writer: type, writer: Writer, row_key: RowKey, quote: bool) !void { switch (row_key) { .str => |value| { - if (stringHasLayoutBreakingControlChar(value)) { - if (value.is8Bit()) { - try JSPrinter.writeJSONString(value.byteSlice(), Writer, writer, .latin1); - } else { - const u16_slice = value.utf16(); - const byte_ptr = @as([*]const u8, @ptrCast(u16_slice.ptr)); - try JSPrinter.writeJSONString(byte_ptr[0 .. u16_slice.len * 2], Writer, writer, .utf16); - } + if (quote) { + try writeQuotedBunString(Writer, writer, value); } else { try writer.print("{f}", .{value}); } @@ -561,12 +574,19 @@ pub const TablePrinter = struct { ) !void { try writer.writeAll("│"); { - const len: u32 = rowKeyWidth(row_key); + // Scan the string row key once for layout-breaking control + // chars and reuse the flag for both width + render so we + // don't walk the bytes twice per row. + const quote_row_key = switch (row_key) { + .str => |value| stringHasLayoutBreakingControlChar(value), + .num => false, + }; + const len: u32 = rowKeyWidthWithQuote(row_key, quote_row_key); const needed = columns.items[0].width -| len; // Right-align the number column try writer.splatByteAll(' ', needed + PADDING); - try writeRowKey(Writer, writer, row_key); + try writeRowKeyWithQuote(Writer, writer, row_key, quote_row_key); try writer.splatByteAll(' ', PADDING); } From b09d145f62cf7b0025f5d59d7fac7315901f20a5 Mon Sep 17 00:00:00 2001 From: robobun Date: Tue, 5 May 2026 07:08:24 +0000 Subject: [PATCH 09/11] ci: retrigger after buildkite agent capacity expiry on build #51631 From 06a0386e4afb589315010ec654c1ea4b7c38fac3 Mon Sep 17 00:00:00 2001 From: robobun Date: Tue, 5 May 2026 07:36:51 +0000 Subject: [PATCH 10/11] test: move #29082 cases into the module's console-table.test.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the repo convention (test/CLAUDE.md, root CLAUDE.md), test/regression/issue/N.test.ts is reserved for true regressions — behavior that worked in a previous release and then broke. console.table never escaped control characters before this PR (quote_strings was unconditionally false for string cells), so this is never-correct behavior, not a regression, and belongs in the module's existing feature test file. Append the 12 cases as a new describe.concurrent block in test/js/bun/console/console-table.test.ts, reusing that file's existing runTable subprocess helper so subprocess startup stays parallelized. Keep the discriminating assertRectangular + toContain pattern — they pinpoint the escaping regression without depending on snapshot diffs. --- test/js/bun/console/console-table.test.ts | 124 ++++++++++++++++++ test/regression/issue/29082.test.ts | 148 ---------------------- 2 files changed, 124 insertions(+), 148 deletions(-) delete mode 100644 test/regression/issue/29082.test.ts diff --git a/test/js/bun/console/console-table.test.ts b/test/js/bun/console/console-table.test.ts index 84e6e8902beb..d62644bf6012 100644 --- a/test/js/bun/console/console-table.test.ts +++ b/test/js/bun/console/console-table.test.ts @@ -231,3 +231,127 @@ test("console.table repeat 50", async () => { expect(stdout).toBe(expected.repeat(50)); expect(stderr).toBe(""); }); + +// https://github.com/oven-sh/bun/issues/29082 — cells containing C0 control +// characters used to be emitted raw, so an embedded \n moved the cursor +// mid-row and broke the table border. These tests exercise the targeted fix +// with discriminating assertions rather than snapshots, so a regression here +// points straight at the escaping logic instead of a snapshot diff. +describe.concurrent("console.table control-character escaping", () => { + // Every `│`-delimited row must have the same number of separators as the + // header — if any cell leaked an embedded newline, the count would differ. + function assertRectangular(out: string) { + const rows = out + .split("\n") + .filter(l => l.trim().length > 0) + .filter(l => l.startsWith("│")); + expect(rows.length).toBeGreaterThan(0); + const expectedBars = rows[0]!.split("│").length; + for (const row of rows) { + expect(row.split("│").length).toBe(expectedBars); + } + } + + test("newline keeps the row on a single line", async () => { + const out = await runTable(`(() => [{ foo: 123, bar: "Hello\\nWorld" }])`); + assertRectangular(out); + expect(out).toContain(`"Hello\\nWorld"`); + // No raw literal newline mid-cell. + expect(out).not.toMatch(/│[^│\n]*Hello\n/); + }); + + test("carriage return", async () => { + const out = await runTable(`(() => [{ bar: "Line1\\rLine2" }])`); + assertRectangular(out); + expect(out).toContain(`"Line1\\rLine2"`); + }); + + test("tab", async () => { + const out = await runTable(`(() => [{ bar: "tab\\there" }])`); + assertRectangular(out); + expect(out).toContain(`"tab\\there"`); + }); + + test("other C0 control chars (vertical tab, form feed, NUL)", async () => { + // \v (0x0B), \f (0x0C), and \0 (NUL) also move the cursor or mismatch + // the visible-width calculation — the fix covers the full C0 range + // (0x00–0x1F except ESC), not just \n/\r/\t. + const out = await runTable(`(() => [{ bar: "a\\vb\\fc\\x00d" }])`); + assertRectangular(out); + // Positive: cell rendered in its JSON-escaped form — \v/\f as short + // escapes, NUL as \u0000. + expect(out).toContain(`"a\\vb\\fc\\u0000d"`); + // Negative: no C0 char survives raw (ESC 0x1B excluded — see ANSI test). + expect(out).not.toMatch(/[\x00-\x08\x0B\x0C\x0E-\x1A\x1C-\x1F]/); + }); + + test("ANSI escape sequences (ESC) pass through unescaped so colors survive", async () => { + // 0x1B is the first byte of every ANSI color sequence. VisibleCharacterCounter + // already strips ANSI from the width calculation, so quoting these strings + // would destroy chalk/picocolors output without fixing any layout bug. + const out = await runTable( + `(() => [[{ status: "\\x1b[31mFAIL\\x1b[0m" }, { status: "\\x1b[32mOK\\x1b[0m" }]])`, + ); + assertRectangular(out); + expect(out).toContain("\x1b[31mFAIL\x1b[0m"); + expect(out).toContain("\x1b[32mOK\x1b[0m"); + expect(out).not.toContain("\\u001b"); + expect(out).not.toContain("\\u001B"); + }); + + test("plain strings stay unquoted", async () => { + const out = await runTable(`(() => [{ foo: 123, bar: "Hello World" }])`); + assertRectangular(out); + expect(out).toContain("Hello World"); + // Plain strings are NOT promoted to the quoted form. + expect(out).not.toContain(`"Hello World"`); + expect(out).not.toContain(`'Hello World'`); + }); + + test("multiple newline cells in the same table", async () => { + const out = await runTable(`(() => [[{ a: 1, b: "a\\nb\\nc" }, { a: 2, b: "plain" }]])`); + assertRectangular(out); + expect(out).toContain(`"a\\nb\\nc"`); + expect(out).toContain("plain"); + }); + + test("newlines in Map values", async () => { + const out = await runTable(`(() => [new Map([["k1", "v1"], ["k2", "v\\n2"]])])`); + assertRectangular(out); + expect(out).toContain(`"v\\n2"`); + }); + + test("newlines in Set values", async () => { + const out = await runTable(`(() => [new Set(["a", "b\\nc"])])`); + assertRectangular(out); + expect(out).toContain(`"b\\nc"`); + }); + + test("newlines in primitive arrays", async () => { + const out = await runTable(`(() => [["hi", "a\\nb", "foo"]])`); + assertRectangular(out); + expect(out).toContain(`"a\\nb"`); + // Plain entries stay unquoted. + const rows = out.split("\n").filter(l => l.startsWith("│")); + expect(rows.some(r => r.includes(" hi "))).toBe(true); + expect(rows.some(r => r.includes(" foo "))).toBe(true); + }); + + test("properties arg respects newline escaping", async () => { + const out = await runTable(`(() => [[{a:1, b:"x\\ny"}, {a:2, b:"normal"}], ["b"]])`); + assertRectangular(out); + expect(out).toContain(`"x\\ny"`); + expect(out).toContain("normal"); + }); + + test("object property keys with newlines are escaped in the index column", async () => { + // When `console.table(obj)` is called with a plain object, the keys + // populate the index column. A key containing \n used to emit a + // literal newline in the index column and break the row layout the + // same way data cells did before the fix. + const out = await runTable(`(() => [{ ["a\\nb"]: 1, normal: 2 }])`); + assertRectangular(out); + expect(out).toContain(`"a\\nb"`); + expect(out).toContain("normal"); + }); +}); diff --git a/test/regression/issue/29082.test.ts b/test/regression/issue/29082.test.ts deleted file mode 100644 index f1b74727a157..000000000000 --- a/test/regression/issue/29082.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -// https://github.com/oven-sh/bun/issues/29082 -import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; - -async function runTable(code: string): Promise<{ stdout: string; exitCode: number }> { - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", code], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - // Drain stderr even though we don't inspect it — leaving it buffered would - // deadlock once the child wrote >~64KB to stderr. - const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - return { stdout, exitCode }; -} - -// Every `│`-delimited row must have the same number of separators as the -// header — if any cell leaked an embedded newline, the count would differ. -function assertRectangular(out: string) { - const rows = out - .split("\n") - .filter(l => l.trim().length > 0) - .filter(l => l.startsWith("│")); - expect(rows.length).toBeGreaterThan(0); - const expectedBars = rows[0]!.split("│").length; - for (const row of rows) { - expect(row.split("│").length).toBe(expectedBars); - } -} - -describe.concurrent("console.table quotes cells containing control characters", () => { - test("newline keeps the row on a single line", async () => { - const { stdout, exitCode } = await runTable(`console.table([{ foo: 123, bar: "Hello\\nWorld" }]);`); - assertRectangular(stdout); - expect(stdout).toContain(`"Hello\\nWorld"`); - // No raw literal newline mid-cell. - expect(stdout).not.toMatch(/│[^│\n]*Hello\n/); - expect(exitCode).toBe(0); - }); - - test("carriage return", async () => { - const { stdout, exitCode } = await runTable(`console.table([{ bar: "Line1\\rLine2" }]);`); - assertRectangular(stdout); - expect(stdout).toContain(`"Line1\\rLine2"`); - expect(exitCode).toBe(0); - }); - - test("tab", async () => { - const { stdout, exitCode } = await runTable(`console.table([{ bar: "tab\\there" }]);`); - assertRectangular(stdout); - expect(stdout).toContain(`"tab\\there"`); - expect(exitCode).toBe(0); - }); - - test("other C0 control chars (vertical tab, form feed, NUL)", async () => { - // \v (0x0B), \f (0x0C), and \0 (NUL) also move the cursor or mismatch - // the visible-width calculation — the fix covers the full C0 range - // (0x00–0x1F except ESC), not just \n/\r/\t. - const { stdout, exitCode } = await runTable(`console.table([{ bar: "a\\vb\\fc\\x00d" }]);`); - assertRectangular(stdout); - // Positive: cell rendered in its JSON-escaped form — \v/\f as short - // escapes, NUL as \u0000. - expect(stdout).toContain(`"a\\vb\\fc\\u0000d"`); - // Negative: no C0 char survives raw (ESC 0x1B excluded — see ANSI test). - expect(stdout).not.toMatch(/[\x00-\x08\x0B\x0C\x0E-\x1A\x1C-\x1F]/); - expect(exitCode).toBe(0); - }); - - test("ANSI escape sequences (ESC) pass through unescaped so colors survive", async () => { - // 0x1B is the first byte of every ANSI color sequence. VisibleCharacterCounter - // already strips ANSI from the width calculation, so quoting these strings - // would destroy chalk/picocolors output without fixing any layout bug. - const { stdout, exitCode } = await runTable( - `console.table([{ status: "\\x1b[31mFAIL\\x1b[0m" }, { status: "\\x1b[32mOK\\x1b[0m" }]);`, - ); - assertRectangular(stdout); - // Cells contain the raw ESC bytes, NOT the JSON-escaped form. - expect(stdout).toContain("\x1b[31mFAIL\x1b[0m"); - expect(stdout).toContain("\x1b[32mOK\x1b[0m"); - expect(stdout).not.toContain("\\u001b"); - expect(stdout).not.toContain("\\u001B"); - expect(exitCode).toBe(0); - }); - - test("plain strings stay unquoted", async () => { - const { stdout, exitCode } = await runTable(`console.table([{ foo: 123, bar: "Hello World" }]);`); - assertRectangular(stdout); - expect(stdout).toContain("Hello World"); - // Plain strings are NOT promoted to the quoted form. - expect(stdout).not.toContain(`"Hello World"`); - expect(stdout).not.toContain(`'Hello World'`); - expect(exitCode).toBe(0); - }); - - test("multiple newline cells in the same table", async () => { - const { stdout, exitCode } = await runTable(`console.table([{ a: 1, b: "a\\nb\\nc" }, { a: 2, b: "plain" }]);`); - assertRectangular(stdout); - expect(stdout).toContain(`"a\\nb\\nc"`); - expect(stdout).toContain("plain"); - expect(exitCode).toBe(0); - }); - - test("newlines in Map values", async () => { - const { stdout, exitCode } = await runTable(`console.table(new Map([["k1", "v1"], ["k2", "v\\n2"]]));`); - assertRectangular(stdout); - expect(stdout).toContain(`"v\\n2"`); - expect(exitCode).toBe(0); - }); - - test("newlines in Set values", async () => { - const { stdout, exitCode } = await runTable(`console.table(new Set(["a", "b\\nc"]));`); - assertRectangular(stdout); - expect(stdout).toContain(`"b\\nc"`); - expect(exitCode).toBe(0); - }); - - test("newlines in primitive arrays", async () => { - const { stdout, exitCode } = await runTable(`console.table(["hi", "a\\nb", "foo"]);`); - assertRectangular(stdout); - expect(stdout).toContain(`"a\\nb"`); - // Plain entries stay unquoted. - const rows = stdout.split("\n").filter(l => l.startsWith("│")); - expect(rows.some(r => r.includes(" hi "))).toBe(true); - expect(rows.some(r => r.includes(" foo "))).toBe(true); - expect(exitCode).toBe(0); - }); - - test("properties arg respects newline escaping", async () => { - const { stdout, exitCode } = await runTable(`console.table([{a:1, b:"x\\ny"}, {a:2, b:"normal"}], ["b"]);`); - assertRectangular(stdout); - expect(stdout).toContain(`"x\\ny"`); - expect(stdout).toContain("normal"); - expect(exitCode).toBe(0); - }); - - test("object property keys with newlines are escaped in the index column", async () => { - // When `console.table(obj)` is called with a plain object, the keys - // populate the index column. A key containing \n used to emit a - // literal newline in the index column and break the row layout the - // same way data cells did before the fix. - const { stdout, exitCode } = await runTable(`console.table({ ["a\\nb"]: 1, normal: 2 });`); - assertRectangular(stdout); - expect(stdout).toContain(`"a\\nb"`); - expect(stdout).toContain("normal"); - expect(exitCode).toBe(0); - }); -}); From 581f051d48e9a2c0082154b1db922a6b25781d28 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 07:38:39 +0000 Subject: [PATCH 11/11] [autofix.ci] apply automated fixes --- test/js/bun/console/console-table.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/js/bun/console/console-table.test.ts b/test/js/bun/console/console-table.test.ts index d62644bf6012..a3c3d7416ab4 100644 --- a/test/js/bun/console/console-table.test.ts +++ b/test/js/bun/console/console-table.test.ts @@ -289,9 +289,7 @@ describe.concurrent("console.table control-character escaping", () => { // 0x1B is the first byte of every ANSI color sequence. VisibleCharacterCounter // already strips ANSI from the width calculation, so quoting these strings // would destroy chalk/picocolors output without fixing any layout bug. - const out = await runTable( - `(() => [[{ status: "\\x1b[31mFAIL\\x1b[0m" }, { status: "\\x1b[32mOK\\x1b[0m" }]])`, - ); + const out = await runTable(`(() => [[{ status: "\\x1b[31mFAIL\\x1b[0m" }, { status: "\\x1b[32mOK\\x1b[0m" }]])`); assertRectangular(out); expect(out).toContain("\x1b[31mFAIL\x1b[0m"); expect(out).toContain("\x1b[32mOK\x1b[0m");