diff --git a/bench/bun.lockb b/bench/bun.lockb index 679b4cb92b..2b23aa48ae 100755 Binary files a/bench/bun.lockb and b/bench/bun.lockb differ diff --git a/bench/package.json b/bench/package.json index d62970fbdf..3cafcffe82 100644 --- a/bench/package.json +++ b/bench/package.json @@ -10,7 +10,8 @@ "eventemitter3": "^5.0.0", "fast-glob": "3.3.1", "fdir": "^6.1.0", - "mitata": "^0.1.6" + "mitata": "^0.1.6", + "string-width": "^7.0.0" }, "scripts": { "ffi": "cd ffi && bun run deps && bun run build && bun run bench", diff --git a/bench/snippets/string-width.mjs b/bench/snippets/string-width.mjs new file mode 100644 index 0000000000..63e1f4ecb5 --- /dev/null +++ b/bench/snippets/string-width.mjs @@ -0,0 +1,44 @@ +import { bench, run } from "./runner.mjs"; +import npmStringWidth from "string-width"; + +const bunStringWidth = globalThis?.Bun?.stringWidth; + +bench("npm/string-width (ansi + emoji + ascii)", () => { + npmStringWidth("hello there! 😀\u001b[31m😀😀"); +}); + +bench("npm/string-width (ansi + emoji)", () => { + npmStringWidth("😀\u001b[31m😀😀"); +}); + +bench("npm/string-width (ansi + ascii)", () => { + npmStringWidth("\u001b[31mhello there!"); +}); + +if (bunStringWidth) { + bench("Bun.stringWidth (ansi + emoji + ascii)", () => { + bunStringWidth("hello there! 😀\u001b[31m😀😀"); + }); + + bench("Bun.stringWidth (ansi + emoji)", () => { + bunStringWidth("😀\u001b[31m😀😀"); + }); + + bench("Bun.stringWidth (ansi + ascii)", () => { + bunStringWidth("\u001b[31mhello there!"); + }); + + if (npmStringWidth("😀\u001b[31m😀😀") !== bunStringWidth("😀\u001b[31m😀😀")) { + console.error("string-width mismatch"); + } + + if (npmStringWidth("hello there! 😀\u001b[31m😀😀") !== bunStringWidth("hello there! 😀\u001b[31m😀😀")) { + console.error("string-width mismatch"); + } + + if (npmStringWidth("\u001b[31mhello there!") !== bunStringWidth("\u001b[31mhello there!")) { + console.error("string-width mismatch"); + } +} + +await run(); diff --git a/docs/api/utils.md b/docs/api/utils.md index 0347da8752..b6089338b2 100644 --- a/docs/api/utils.md +++ b/docs/api/utils.md @@ -261,6 +261,24 @@ This function is optimized for large input. On an M1X, it processes 480 MB/s - 20 GB/s, depending on how much data is being escaped and whether there is non-ascii text. Non-string types will be converted to a string before escaping. +## `Bun.stringWidth()` + +```ts +Bun.stringWidth(input: string, options?: { countAnsiEscapeCodes?: boolean = false }): number +``` + +Returns the number of columns required to display a string. This is useful for aligning text in a terminal. By default, ANSI escape codes are removed before measuring the string. To include them, pass `{ countAnsiEscapeCodes: true }` as the second argument. + +```ts +Bun.stringWidth("hello"); // => 5 +Bun.stringWidth("\u001b[31mhello\u001b[0m"); // => 5 +Bun.stringWidth("\u001b[31mhello\u001b[0m", { countAnsiEscapeCodes: true }); // => 12 +``` + +Compared with the popular `string-width` npm package, `bun`'s implementation is > [100x faster](https://github.com/oven-sh/bun/blob/8abd1fb088bcf2e78bd5d0d65ba4526872d2ab61/bench/snippets/string-width.mjs#L22) + + + ## `Bun.fileURLToPath()` diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 3d7af020cb..86c9d98127 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -4497,6 +4497,30 @@ declare module "bun" { "ignore" | "inherit" | null | undefined >; + /** + * + * Count the visible width of a string, as it would be displayed in a terminal. + * + * By default, strips ANSI escape codes before measuring the string. This is + * because ANSI escape codes are not visible characters. If passed a non-string, + * it will return 0. + * + * @param str The string to measure + * @param options + */ + function stringWidth( + str: string, + options?: { + /** + * Whether to include ANSI escape codes in the width calculation + * + * Slightly faster if set to `false`, but less accurate if the string contains ANSI escape codes. + * @default false + */ + countAnsiEscapeCodes?: boolean; + }, + ): number; + class FileSystemRouter { /** * Create a new {@link FileSystemRouter}. diff --git a/src/bun.js/ConsoleObject.zig b/src/bun.js/ConsoleObject.zig index 1369d0bf98..a332cd3bc5 100644 --- a/src/bun.js/ConsoleObject.zig +++ b/src/bun.js/ConsoleObject.zig @@ -284,12 +284,12 @@ const TablePrinter = struct { ); pub fn write(this: VisibleCharacterCounter, bytes: []const u8) WriteError!usize { - this.width.* += strings.visibleUTF8Width(bytes); + this.width.* += strings.visible.width.exclude_ansi_colors.utf8(bytes); return bytes.len; } pub fn writeAll(this: VisibleCharacterCounter, bytes: []const u8) WriteError!void { - this.width.* += strings.visibleUTF8Width(bytes); + this.width.* += strings.width.exclude_ansi_colors.utf8(bytes); } }; @@ -320,7 +320,7 @@ const TablePrinter = struct { fn updateColumnsForRow(this: *TablePrinter, columns: *std.ArrayList(Column), row_key: RowKey, row_value: JSValue) !void { // update size of "(index)" column const row_key_len: u32 = switch (row_key) { - .str => |value| @intCast(value.visibleWidth()), + .str => |value| @intCast(value.visibleWidthExcludeANSIColors()), .num => |value| @truncate(bun.fmt.fastDigitCount(value)), }; columns.items[0].width = @max(columns.items[0].width, row_key_len); @@ -400,7 +400,7 @@ const TablePrinter = struct { try writer.writeAll("│"); { const len: u32 = switch (row_key) { - .str => |value| @truncate(value.visibleWidth()), + .str => |value| @truncate(value.visibleWidthExcludeANSIColors()), .num => |value| @truncate(bun.fmt.fastDigitCount(value)), }; const needed = columns.items[0].width -| len; @@ -544,7 +544,7 @@ const TablePrinter = struct { { for (columns.items) |*col| { // also update the col width with the length of the column name itself - col.width = @max(col.width, @as(u32, @intCast(col.name.visibleWidth()))); + col.width = @max(col.width, @as(u32, @intCast(col.name.visibleWidthExcludeANSIColors()))); } try writer.writeAll("┌"); @@ -557,7 +557,7 @@ const TablePrinter = struct { for (columns.items, 0..) |col, i| { if (i > 0) try writer.writeAll("│"); - const len = col.name.visibleWidth(); + const len = col.name.visibleWidthExcludeANSIColors(); const needed = col.width -| len; try writer.writeByteNTimes(' ', 1); if (comptime enable_ansi_colors) { diff --git a/src/bun.js/api/bun.zig b/src/bun.js/api/bun.zig index 9765b074a8..d07823a6d7 100644 --- a/src/bun.js/api/bun.zig +++ b/src/bun.js/api/bun.zig @@ -41,7 +41,7 @@ pub const BunObject = struct { pub const spawnSync = JSC.wrapStaticMethod(JSC.Subprocess, "spawnSync", false); pub const which = Bun.which; pub const write = JSC.WebCore.Blob.writeFile; - // pub const @"$" = Bun.shell; + pub const stringWidth = Bun.stringWidth; pub const shellParse = Bun.shellParse; pub const shellLex = Bun.shellLex; pub const braces = Bun.braces; @@ -161,7 +161,7 @@ pub const BunObject = struct { @export(BunObject.spawnSync, .{ .name = callbackName("spawnSync") }); @export(BunObject.which, .{ .name = callbackName("which") }); @export(BunObject.write, .{ .name = callbackName("write") }); - // @export(BunObject.@"$", .{ .name = callbackName("$") }); + @export(BunObject.stringWidth, .{ .name = callbackName("stringWidth") }); @export(BunObject.shellParse, .{ .name = callbackName("shellParse") }); @export(BunObject.shellLex, .{ .name = callbackName("shellLex") }); @export(BunObject.shellEscape, .{ .name = callbackName("shellEscape") }); @@ -5108,6 +5108,34 @@ pub const FFIObject = struct { } }; +fn stringWidth(globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSC.JSValue { + const arguments = callframe.arguments(2).slice(); + const value = if (arguments.len > 0) arguments[0] else JSC.JSValue.jsUndefined(); + const options_object = if (arguments.len > 1) arguments[1] else JSC.JSValue.jsUndefined(); + + if (!value.isString()) { + return JSC.jsNumber(0); + } + + const str = value.toBunString(globalObject); + defer str.deref(); + + var count_ansi_escapes = false; + + if (options_object.isObject()) { + if (options_object.getTruthy(globalObject, "countAnsiEscapeCodes")) |count_ansi_escapes_value| { + if (count_ansi_escapes_value.isBoolean()) + count_ansi_escapes = count_ansi_escapes_value.toBoolean(); + } + } + + if (count_ansi_escapes) { + return JSC.jsNumber(str.visibleWidth()); + } + + return JSC.jsNumber(str.visibleWidthExcludeANSIColors()); +} + /// EnvironmentVariables is runtime defined. /// Also, you can't iterate over process.env normally since it only exists at build-time otherwise // This is aliased to Bun.env diff --git a/src/bun.js/bindings/BunObject+exports.h b/src/bun.js/bindings/BunObject+exports.h index 63c71a0fee..d590963521 100644 --- a/src/bun.js/bindings/BunObject+exports.h +++ b/src/bun.js/bindings/BunObject+exports.h @@ -66,6 +66,7 @@ macro(spawnSync) \ macro(which) \ macro(write) \ + macro(stringWidth) \ macro(shellParse) \ macro(shellLex) \ macro(shellEscape) \ diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp index 5a8958925f..91e412de10 100644 --- a/src/bun.js/bindings/BunObject.cpp +++ b/src/bun.js/bindings/BunObject.cpp @@ -621,6 +621,7 @@ JSC_DEFINE_HOST_FUNCTION(functionHashCode, stdin BunObject_getter_wrap_stdin DontDelete|PropertyCallback stdout BunObject_getter_wrap_stdout DontDelete|PropertyCallback stringHashCode functionHashCode DontDelete|Function 1 + stringWidth BunObject_callback_stringWidth DontDelete|Function 2 unsafe BunObject_getter_wrap_unsafe DontDelete|PropertyCallback version constructBunVersion ReadOnly|DontDelete|PropertyCallback which BunObject_callback_which DontDelete|Function 1 diff --git a/src/string.zig b/src/string.zig index 65f56b9347..9addb67072 100644 --- a/src/string.zig +++ b/src/string.zig @@ -940,11 +940,21 @@ pub const String = extern struct { pub fn visibleWidth(this: *const String) usize { if (this.isUTF8()) { - return bun.strings.visibleUTF8Width(this.utf8()); + return bun.strings.visible.width.utf8(this.utf8()); } else if (this.isUTF16()) { - return bun.strings.visibleUTF16Width(this.utf16()); + return bun.strings.visible.width.utf16(this.utf16()); } else { - return bun.strings.visibleLatin1Width(this.latin1()); + return bun.strings.visible.width.ascii(this.latin1()); + } + } + + pub fn visibleWidthExcludeANSIColors(this: *const String) usize { + if (this.isUTF8()) { + return bun.strings.visible.width.exclude_ansi_colors.utf8(this.utf8()); + } else if (this.isUTF16()) { + return bun.strings.visible.width.exclude_ansi_colors.utf16(this.utf16()); + } else { + return bun.strings.visible.width.exclude_ansi_colors.ascii(this.latin1()); } } diff --git a/src/string_immutable.zig b/src/string_immutable.zig index 5824765d72..92c59a9cb3 100644 --- a/src/string_immutable.zig +++ b/src/string_immutable.zig @@ -3793,6 +3793,10 @@ pub fn indexOfCharUsize(slice: []const u8, char: u8) ?usize { return i; } +pub fn indexOfChar16Usize(slice: []const u16, char: u16) ?usize { + return std.mem.indexOfScalar(u16, slice, char); +} + test "indexOfChar" { const pairs = .{ .{ @@ -5659,89 +5663,145 @@ pub fn visibleCodepointWidthType(comptime T: type, cp: T) usize { return 1; } -pub fn visibleASCIIWidth(input_: anytype) usize { - var length: usize = 0; - var input = input_; +pub const visible = struct { + fn visibleASCIIWidth(input_: anytype) usize { + var length: usize = 0; + var input = input_; + + if (comptime Environment.enableSIMD) { + // https://zig.godbolt.org/z/hxhjncvq7 + const ElementType = std.meta.Child(@TypeOf(input_)); + const simd = 16 / @sizeOf(ElementType); + if (input.len >= simd) { + const input_end = input.ptr + input.len - (input.len % simd); + while (input.ptr != input_end) { + const chunk: @Vector(simd, ElementType) = input[0..simd].*; + input = input[simd..]; + + const cmp: @Vector(simd, ElementType) = @splat(0x1f); + const match1: @Vector(simd, u1) = @bitCast(chunk >= cmp); + const match: @Vector(simd, ElementType) = match1; + + length += @reduce(.Add, match); + } + } + + // this is a deliberate compiler optimization + // it disables auto-vectorizing the "input" for loop. + if (!(input.len < simd)) unreachable; + } + + for (input) |c| { + length += if (c > 0x1f) 1 else 0; + } + + return length; + } + + fn visibleASCIIWidthExcludeANSIColors(input_: anytype) usize { + var length: usize = 0; + var input = input_; - if (comptime Environment.enableSIMD) { - // https://zig.godbolt.org/z/hxhjncvq7 const ElementType = std.meta.Child(@TypeOf(input_)); - const simd = 16 / @sizeOf(ElementType); - if (input.len >= simd) { - const input_end = input.ptr + input.len - (input.len % simd); - while (input.ptr != input_end) { - const chunk: @Vector(simd, ElementType) = input[0..simd].*; - input = input[simd..]; + const indexFn = if (comptime ElementType == u8) strings.indexOfCharUsize else strings.indexOfChar16Usize; - const cmp: @Vector(simd, ElementType) = @splat(0x1f); - const match1: @Vector(simd, u1) = @bitCast(chunk >= cmp); - const match: @Vector(simd, ElementType) = match1; + while (indexFn(input, '\x1b')) |i| { + length += visibleASCIIWidth(input[0..i]); + input = input[i..]; - length += @reduce(.Add, match); + if (input.len < 3) return length; + + if (input[1] == '[') { + const end = indexFn(input[2..], 'm') orelse return length; + input = input[end + 3 ..]; + } else { + input = input[1..]; } } - // this is a deliberate compiler optimization - // it disables auto-vectorizing the "input" for loop. - if (!(input.len < simd)) unreachable; + length += visibleASCIIWidth(input); + + return length; } - for (input) |c| { - length += if (c > 0x1f) 1 else 0; + fn visibleUTF8WidthFn(input: []const u8, comptime asciiFn: anytype) usize { + var bytes = input; + var len: usize = 0; + while (bun.strings.firstNonASCII(bytes)) |i| { + len += asciiFn(bytes[0..i]); + + const byte = bytes[i]; + const skip = bun.strings.wtf8ByteSequenceLengthWithInvalid(byte); + const cp_bytes: [4]u8 = switch (skip) { + inline 1, 2, 3, 4 => |cp_len| .{ + byte, + if (comptime cp_len > 1) bytes[1] else 0, + if (comptime cp_len > 2) bytes[2] else 0, + if (comptime cp_len > 3) bytes[3] else 0, + }, + else => unreachable, + }; + + const cp = decodeWTF8RuneTMultibyte(&cp_bytes, skip, u32, unicode_replacement); + len += visibleCodepointWidthType(u32, cp); + + bytes = bytes[@min(i + skip, bytes.len)..]; + } + + len += asciiFn(bytes); + + return len; } - return length; -} + fn visibleUTF16WidthFn(input: []const u16, comptime asciiFn: anytype) usize { + var bytes = input; + var len: usize = 0; + while (bun.strings.firstNonASCII16CheckMin([]const u16, bytes, false)) |i| { + len += asciiFn(bytes[0..i]); + bytes = bytes[i..]; -pub fn visibleUTF8Width(input: []const u8) usize { - var bytes = input; - var len: usize = 0; - while (bun.strings.firstNonASCII(bytes)) |i| { - len += visibleASCIIWidth(bytes[0..i]); + const utf8 = utf16CodepointWithFFFD([]const u16, bytes); + len += visibleCodepointWidthType(u32, utf8.code_point); + bytes = bytes[@min(@as(usize, utf8.len), bytes.len)..]; + } - const byte = bytes[i]; - const skip = bun.strings.wtf8ByteSequenceLengthWithInvalid(byte); - const cp_bytes: [4]u8 = switch (skip) { - inline 1, 2, 3, 4 => |cp_len| .{ - byte, - if (comptime cp_len > 1) bytes[1] else 0, - if (comptime cp_len > 2) bytes[2] else 0, - if (comptime cp_len > 3) bytes[3] else 0, - }, - else => unreachable, + len += asciiFn(bytes); + + return len; + } + + fn visibleLatin1WidthFn(input: []const u8) usize { + return visibleASCIIWidth(input); + } + + pub const width = struct { + pub fn ascii(input: []const u8) usize { + return visibleASCIIWidth(input); + } + + pub fn utf8(input: []const u8) usize { + return visibleUTF8WidthFn(input, visibleASCIIWidth); + } + + pub fn utf16(input: []const u16) usize { + return visibleUTF16WidthFn(input, visibleASCIIWidth); + } + + pub const exclude_ansi_colors = struct { + pub fn ascii(input: []const u8) usize { + return visibleASCIIWidthExcludeANSIColors(input); + } + + pub fn utf8(input: []const u8) usize { + return visibleUTF8WidthFn(input, visibleASCIIWidthExcludeANSIColors); + } + + pub fn utf16(input: []const u16) usize { + return visibleUTF16WidthFn(input, visibleASCIIWidthExcludeANSIColors); + } }; - - const cp = decodeWTF8RuneTMultibyte(&cp_bytes, skip, u32, unicode_replacement); - len += visibleCodepointWidthType(u32, cp); - - bytes = bytes[@min(i + skip, bytes.len)..]; - } - - len += visibleASCIIWidth(bytes); - - return len; -} - -pub fn visibleUTF16Width(input: []const u16) usize { - var bytes = input; - var len: usize = 0; - while (bun.strings.firstNonASCII16CheckMin([]const u16, bytes, false)) |i| { - len += visibleASCIIWidth(bytes[0..i]); - bytes = bytes[i..]; - - const utf8 = utf16CodepointWithFFFD([]const u16, bytes); - len += visibleCodepointWidthType(u32, utf8.code_point); - bytes = bytes[@min(@as(usize, utf8.len), bytes.len)..]; - } - - len += visibleASCIIWidth(bytes); - - return len; -} - -pub fn visibleLatin1Width(input: []const u8) usize { - return visibleASCIIWidth(input); -} + }; +}; pub const QuoteEscapeFormat = struct { data: []const u8, diff --git a/test/bun.lockb b/test/bun.lockb index 4289360c49..9484b06eca 100755 Binary files a/test/bun.lockb and b/test/bun.lockb differ diff --git a/test/js/bun/console/__snapshots__/console-table.test.ts.snap b/test/js/bun/console/__snapshots__/console-table.test.ts.snap index 8fbb3129ea..beda30eb39 100644 --- a/test/js/bun/console/__snapshots__/console-table.test.ts.snap +++ b/test/js/bun/console/__snapshots__/console-table.test.ts.snap @@ -174,3 +174,14 @@ exports[`console.table expected output for: headers object 1`] = ` └───┴────────┴────────┘ " `; + +exports[`console.table ansi colors 1`] = ` +"┌───────┬─────────────────────────────────────────────┐ +│ │ Values │ +├───────┼─────────────────────────────────────────────┤ +│ hello │ this is a long string with ansi color codes │ +│ world │ this is another long string with ansi color │ +│ foo │ bar │ +└───────┴─────────────────────────────────────────────┘ +" +`; diff --git a/test/js/bun/console/console-table.test.ts b/test/js/bun/console/console-table.test.ts index 208d55893d..f1fc034800 100644 --- a/test/js/bun/console/console-table.test.ts +++ b/test/js/bun/console/console-table.test.ts @@ -159,6 +159,32 @@ test("console.table json fixture", () => { console.log(actualOutput); }); +test("console.table ansi colors", () => { + const obj = { + [ansify("hello")]: ansify("this is a long string with ansi color codes"), + [ansify("world")]: ansify("this is another long string with ansi color"), + [ansify("foo")]: ansify("bar"), + }; + + function ansify(str: string) { + return `\u001b[31m${str}\u001b[39m`; + } + + const { stdout } = spawnSync({ + cmd: [bunExe(), `${import.meta.dir}/console-table-run.ts`, `(() => [${JSON.stringify(obj, null, 2)}])`], + stdout: "pipe", + stderr: "inherit", + env: bunEnv, + }); + + const actualOutput = stdout + .toString() + // todo: fix bug causing this to be necessary: + .replaceAll("`", "'"); + expect(actualOutput).toMatchSnapshot(); + console.log(actualOutput); +}); + test.skip("console.table character widths", () => { // note: this test cannot be automated because cannot test printed witdhs consistently. // so this test is just meant to be run manually diff --git a/test/js/bun/util/stringWidth.test.ts b/test/js/bun/util/stringWidth.test.ts new file mode 100644 index 0000000000..a58fa7b694 --- /dev/null +++ b/test/js/bun/util/stringWidth.test.ts @@ -0,0 +1,85 @@ +import { test, expect, describe } from "bun:test"; + +import npmStringWidth from "string-width"; +import { stringWidth } from "bun"; + +expect.extend({ + toMatchNPMStringWidth(received: string) { + const width = npmStringWidth(received); + const bunWidth = stringWidth(received); + const pass = width === bunWidth; + const message = () => `expected ${received} to have npm string width ${width} but got ${bunWidth}`; + return { pass, message }; + }, + toMatchNPMStringWidthExcludeANSI(received: string) { + const width = npmStringWidth(received, { countAnsiEscapeCodes: false }); + const bunWidth = stringWidth(received, { countAnsiEscapeCodes: false }); + const pass = width === bunWidth; + const message = () => `expected ${received} to have npm string width ${width} but got ${bunWidth}`; + return { pass, message }; + }, +}); + +test("stringWidth", () => { + expect(undefined).toMatchNPMStringWidth(); + expect("").toMatchNPMStringWidth(); + expect("a").toMatchNPMStringWidth(); + expect("ab").toMatchNPMStringWidth(); + expect("abc").toMatchNPMStringWidth(); + expect("😀").toMatchNPMStringWidth(); + expect("😀😀").toMatchNPMStringWidth(); + expect("😀😀😀").toMatchNPMStringWidth(); + expect("😀😀😀😀").toMatchNPMStringWidth(); + expect("😀😀😀😀😀").toMatchNPMStringWidth(); + expect("😀😀😀😀😀😀").toMatchNPMStringWidth(); + expect("😀😀😀😀😀😀😀").toMatchNPMStringWidth(); + expect("😀😀😀😀😀😀😀😀").toMatchNPMStringWidth(); + expect("😀😀😀😀😀😀😀😀😀").toMatchNPMStringWidth(); + expect("😀😀😀😀😀😀😀😀😀😀").toMatchNPMStringWidth(); +}); + +for (let matcher of ["toMatchNPMStringWidth", "toMatchNPMStringWidthExcludeANSI"]) { + describe(matcher, () => { + test("ansi colors", () => { + expect("\u001b[31m")[matcher](); + expect("\u001b[31ma")[matcher](); + expect("\u001b[31mab")[matcher](); + expect("\u001b[31mabc")[matcher](); + expect("\u001b[31m😀")[matcher](); + expect("\u001b[31m😀😀")[matcher](); + expect("\u001b[31m😀😀😀")[matcher](); + expect("\u001b[31m😀😀😀😀")[matcher](); + expect("\u001b[31m😀😀😀😀😀")[matcher](); + expect("\u001b[31m😀😀😀😀😀😀")[matcher](); + expect("\u001b[31m😀😀😀😀😀😀😀")[matcher](); + expect("\u001b[31m😀😀😀😀😀😀😀😀")[matcher](); + expect("\u001b[31m😀😀😀😀😀😀😀😀😀")[matcher](); + expect("\u001b[31m😀😀😀😀😀😀😀😀😀😀")[matcher](); + + expect("a\u001b[31m")[matcher](); + expect("ab\u001b[31m")[matcher](); + expect("abc\u001b[31m")[matcher](); + expect("😀\u001b[31m")[matcher](); + expect("😀😀\u001b[31m")[matcher](); + expect("😀😀😀\u001b[31m")[matcher](); + expect("😀😀😀😀\u001b[31m")[matcher](); + expect("😀😀😀😀😀\u001b[31m")[matcher](); + expect("😀😀😀😀😀😀\u001b[31m")[matcher](); + expect("😀😀😀😀😀😀😀\u001b[31m")[matcher](); + expect("😀😀😀😀😀😀😀😀\u001b[31m")[matcher](); + expect("😀😀😀😀😀😀😀😀😀\u001b[31m")[matcher](); + + expect("a\u001b[31mb")[matcher](); + expect("ab\u001b[31mc")[matcher](); + expect("abc\u001b[31m😀")[matcher](); + expect("😀\u001b[31m😀😀")[matcher](); + expect("😀😀\u001b[31m😀😀😀")[matcher](); + expect("😀😀😀\u001b[31m😀😀😀😀")[matcher](); + expect("😀😀😀😀\u001b[31m😀😀😀😀😀")[matcher](); + expect("😀😀😀😀😀\u001b[31m😀😀😀😀😀😀")[matcher](); + expect("😀😀😀😀😀😀\u001b[31m😀😀😀😀😀😀😀")[matcher](); + expect("😀😀😀😀😀😀😀\u001b[31m😀😀😀😀😀😀😀😀")[matcher](); + expect("😀😀😀😀😀😀😀😀\u001b[31m😀😀😀😀😀😀😀😀😀")[matcher](); + }); + }); +} diff --git a/test/package.json b/test/package.json index 8e2f01345c..7f2c75454a 100644 --- a/test/package.json +++ b/test/package.json @@ -41,6 +41,7 @@ "sinon": "6.0.0", "socket.io": "4.7.1", "socket.io-client": "4.7.1", + "string-width": "7.0.0", "supertest": "6.3.3", "svelte": "3.55.1", "typescript": "5.0.2",