Repository: hetznercloud/cli
Pull request #995 (2025-02-28 16:00:16)

refactor: tablewriter that can handle unicode
The previously used `text/tabwriter` does not even attempt to handle Unicode characters that may have a default width != 0. This causes issues in tables where any column has such a Unicode character (like emojis), as the alignment for that row would be broken.

We already use the new package for generating our docs, so this "only" moves it from tools to production dependency.

I tried to copy the previous style as much as possible and left the surrounding code as is. There is some potential for simplification because the new package can handle a lot of the column/view logic for us.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/hetznercloud/cli/pull/995?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 63.67%. Comparing base [(`4aba0bf`)](https://app.codecov.io/gh/hetznercloud/cli/commit/4aba0bf0b61739301602163b811765756f58b001?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud) to head [(`e775751`)](https://app.codecov.io/gh/hetznercloud/cli/commit/e775751465854bf7e03bcc77cd7108e821da33ea?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #995      +/-   ##
==========================================
- Coverage   70.12%   63.67%   -6.46%     
==========================================
  Files         245      243       -2     
  Lines       10756    10717      -39     
==========================================
- Hits         7543     6824     -719     
- Misses       2537     3178     +641     
- Partials      676      715      +39     
```

| [Flag](https://app.codecov.io/gh/hetznercloud/cli/pull/995/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud) | Coverage Δ | |
|---|---|---|
| [e2e](https://app.codecov.io/gh/hetznercloud/cli/pull/995/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud) | `?` | |
| [unit](https://app.codecov.io/gh/hetznercloud/cli/pull/995/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud) | `63.67% <100.00%> (+0.07%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/hetznercloud/cli/pull/995?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hetznercloud).


Patch:
diff --git a/internal/cmd/output/output.go b/internal/cmd/output/output.go
index 6ed1d19..3307760 100644
--- a/internal/cmd/output/output.go
+++ b/internal/cmd/output/output.go
@@ -6,10 +6,10 @@ import (
 	"reflect"
 	"sort"
 	"strings"
-	"text/tabwriter"
 	"unicode"
 
 	"github.com/fatih/structs"
+	"github.com/jedib0t/go-pretty/v6/table"
 	"github.com/spf13/cobra"
 
 	"github.com/hetznercloud/cli/internal/cmd/cmpl"
@@ -149,8 +149,27 @@ func parseOutputFlags(in []string) Opts {
 
 // NewTable creates a new Table.
 func NewTable(out io.Writer) *Table {
+	style := table.Style{
+		Name:    "minimal",
+		Box:     table.StyleBoxLight,
+		Color:   table.ColorOptionsDefault,
+		Format:  table.FormatOptionsDefault,
+		HTML:    table.DefaultHTMLOptions,
+		Options: table.OptionsNoBordersAndSeparators,
+		Size:    table.SizeOptionsDefault,
+		Title:   table.TitleOptionsDefault,
+	}
+	style.Box.MiddleVertical = "   "
+	style.Box.PaddingLeft = ""
+	style.Box.PaddingRight = ""
+	style.Options.SeparateColumns = true
+
+	w := table.NewWriter()
+	w.SetStyle(style)
+
 	return &Table{
-		w:             tabwriter.NewWriter(out, 0, 0, 3, ' ', 0),
+		out:           out,
+		w:             w,
 		columns:       map[string]bool{},
 		fieldMapping:  map[string]FieldFn{},
 		fieldAlias:    map[string]string{},
@@ -160,14 +179,10 @@ func NewTable(out io.Writer) *Table {
 
 type FieldFn func(obj interface{}) string
 
-type writerFlusher interface {
-	io.Writer
-	Flush() error
-}
-
 // Table is a generic way to format object as a table.
 type Table struct {
-	w             writerFlusher
+	out           io.Writer
+	w             table.Writer
 	columns       map[string]bool
 	fieldMapping  map[string]FieldFn
 	fieldAlias    map[string]string
@@ -248,18 +263,21 @@ func (o *Table) ValidateColumns(cols []string) error {
 
 // WriteHeader writes the table header.
 func (o *Table) WriteHeader(columns []string) {
-	var header []string
+	header := table.Row{}
 	for _, col := range columns {
 		if alias, ok := o.fieldAlias[col]; ok {
 			col = alias
 		}
+
 		header = append(header, strings.ReplaceAll(strings.ToUpper(col), "_", " "))
 	}
-	_, _ = fmt.Fprintln(o.w, strings.Join(header, "\t"))
+	o.w.AppendHeader(header)
 }
 
 func (o *Table) Flush() error {
-	return o.w.Flush()
+	_, _ = o.out.Write([]byte(o.w.Render()))
+	_, _ = o.out.Write([]byte("\n"))
+	return nil
 }
 
 // Write writes a table line.
@@ -270,7 +288,7 @@ func (o *Table) Write(columns []string, obj interface{}) {
 		dataL[strings.ToLower(key)] = value
 	}
 
-	var out []string
+	var out table.Row
 	for _, col := range columns {
 		colName := strings.ToLower(col)
 		if alias, ok := o.fieldAlias[colName]; ok {
@@ -299,7 +317,7 @@ func (o *Table) Write(columns []string, obj interface{}) {
 			out = append(out, fmt.Sprintf("%v", value))
 		}
 	}
-	_, _ = fmt.Fprintln(o.w, strings.Join(out, "\t"))
+	o.w.AppendRow(out)
 }
 
 func fieldName(name string) string {


Test patch:
diff --git a/internal/cmd/all/list_test.go b/internal/cmd/all/list_test.go
index 224c686..6fce40b 100644
--- a/internal/cmd/all/list_test.go
+++ b/internal/cmd/all/list_test.go
@@ -179,12 +179,12 @@ func TestListAll(t *testing.T) {
 	expOut := `SERVERS
 ---
 ID    NAME        STATUS    IPV4        IPV6   PRIVATE NET   DATACENTER   AGE
-123   my server   running   192.0.2.1   -      -             hel1-dc2     3d
+123   my server   running   192.0.2.1   -      -             hel1-dc2     3d 
 
 IMAGES
 ---
 ID   TYPE     NAME   DESCRIPTION   ARCHITECTURE   IMAGE SIZE   DISK SIZE   CREATED                        DEPRECATED
-1    backup   test   -             arm            -            20 GB       Wed Aug 20 12:00:00 UTC 2036   -
+1    backup   test   -             arm            -            20 GB       Wed Aug 20 12:00:00 UTC 2036   -         
 
 PLACEMENT GROUPS
 ---
@@ -194,12 +194,12 @@ ID    NAME   SERVERS     TYPE     AGE
 PRIMARY IPS
 ---
 ID    TYPE   NAME   IP          ASSIGNEE   DNS   AUTO DELETE   AGE
-123   ipv4   test   127.0.0.1   -          -     no            2h
+123   ipv4   test   127.0.0.1   -          -     no            2h 
 
 ISOS
 ---
 ID    NAME   DESCRIPTION   TYPE      ARCHITECTURE
-123   test   -             private   arm
+123   test   -             private   arm         
 
 VOLUMES
 ---
@@ -209,12 +209,12 @@ ID    NAME   SIZE    SERVER   LOCATION   AGE
 LOAD BALANCER
 ---
 ID    NAME     HEALTH    IPV4        IPV6   TYPE   LOCATION   NETWORK ZONE   AGE
-123   foobar   healthy   192.0.2.1   ::     lb11   fsn1       eu-central     5h
+123   foobar   healthy   192.0.2.1   ::     lb11   fsn1       eu-central     5h 
 
 FLOATING IPS
 ---
 ID    TYPE   NAME   DESCRIPTION   IP          HOME   SERVER   DNS   AGE
-123   ipv4   test   -             127.0.0.1   fsn1   -        -     1d
+123   ipv4   test   -             127.0.0.1   fsn1   -        -     1d 
 
 NETWORKS
 ---
@@ -223,7 +223,7 @@ ID    NAME       IP RANGE       SERVERS    AGE
 
 FIREWALLS
 ---
-ID    NAME   RULES COUNT   APPLIED TO COUNT
+ID    NAME   RULES COUNT   APPLIED TO COUNT             
 123   test   5 Rules       2 Servers | 0 Label Selectors
 
 CERTIFICATES
@@ -234,7 +234,7 @@ ID    NAME   TYPE      DOMAIN NAMES   NOT VALID AFTER                AGE
 SSH KEYS
 ---
 ID    NAME   FINGERPRINT   AGE
-123   test   -             2h
+123   test   -             2h 
 
 `
 
diff --git a/internal/cmd/base/list_test.go b/internal/cmd/base/list_test.go
index 09b0f70..c3130df 100644
--- a/internal/cmd/base/list_test.go
+++ b/internal/cmd/base/list_test.go
@@ -94,8 +94,12 @@ func TestList(t *testing.T) {
 	const resourceSchema = `[{"id": 123, "name": "test"}, {"id": 456, "name": "test2"}, {"id": 789, "name": "test3"}]`
 	testutil.TestCommand(t, fakeListCmd, map[string]testutil.TestCase{
 		"no flags": {
-			Args:   []string{"list"},
-			ExpOut: "ID    NAME\n123   test\n456   test2\n789   test3\n",
+			Args: []string{"list"},
+			ExpOut: `ID    NAME 
+123   test 
+456   test2
+789   test3
+`,
 		},
 		"json": {
 			Args:       []string{"list", "-o=json"},
@@ -108,8 +112,12 @@ func TestList(t *testing.T) {
 			ExpOutType: testutil.DataTypeYAML,
 		},
 		"quiet": {
-			Args:   []string{"list", "--quiet"},
-			ExpOut: "ID    NAME\n123   test\n456   test2\n789   test3\n",
+			Args: []string{"list", "--quiet"},
+			ExpOut: `ID    NAME 
+123   test 
+456   test2
+789   test3
+`,
 		},
 		"json quiet": {
 			Args:       []string{"list", "-o=json", "--quiet"},
diff --git a/internal/cmd/config/list_test.go b/internal/cmd/config/list_test.go
index da619e3..8e45aed 100644
--- a/internal/cmd/config/list_test.go
+++ b/internal/cmd/config/list_test.go
@@ -58,52 +58,52 @@ func TestList(t *testing.T) {
 		{
 			name: "default",
 			args: []string{},
-			expOut: `KEY                    VALUE
-context                test_context
-debug                  yes
-deeply.nested.option   bar
+			expOut: `KEY                    VALUE                    
+context                test_context             
+debug                  yes                      
+deeply.nested.option   bar                      
 endpoint               https://test-endpoint.com
-poll-interval          1.234s
-quiet                  yes
-token                  [redacted]
+poll-interval          1.234s                   
+quiet                  yes                      
+token                  [redacted]               
 `,
 		},
 		{
 			name: "only key",
 			args: []string{"-o=columns=key"},
-			expOut: `KEY
-context
-debug
+			expOut: `KEY                 
+context             
+debug               
 deeply.nested.option
-endpoint
-poll-interval
-quiet
-token
+endpoint            
+poll-interval       
+quiet               
+token               
 `,
 		},
 		{
 			name: "no header",
 			args: []string{"-o=noheader"},
-			expOut: `context                test_context
-debug                  yes
-deeply.nested.option   bar
+			expOut: `context                test_context             
+debug                  yes                      
+deeply.nested.option   bar                      
 endpoint               https://test-endpoint.com
-poll-interval          1.234s
-quiet                  yes
-token                  [redacted]
+poll-interval          1.234s                   
+quiet                  yes                      
+token                  [redacted]               
 `,
 		},
 		{
 			name: "allow sensitive",
 			args: []string{"--allow-sensitive"},
-			expOut: `KEY                    VALUE
-context                test_context
-debug                  yes
-deeply.nested.option   bar
+			expOut: `KEY                    VALUE                    
+context                test_context             
+debug                  yes                      
+deeply.nested.option   bar                      
 endpoint               https://test-endpoint.com
-poll-interval          1.234s
-quiet                  yes
-token                  super secret token
+poll-interval          1.234s                   
+quiet                  yes                      
+token                  super secret token       
 `,
 		},
 		{
diff --git a/internal/cmd/context/list_test.go b/internal/cmd/context/list_test.go
index 0884d2e..02477ae 100644
--- a/internal/cmd/context/list_test.go
+++ b/internal/cmd/context/list_test.go
@@ -44,8 +44,8 @@ token = "yet another super secret token"
 			name:   "default",
 			args:   []string{},
 			config: testConfig,
-			expOut: `ACTIVE   NAME
-*        my-context
+			expOut: `ACTIVE   NAME            
+*        my-context      
          my-other-context
          my-third-context
 `,
@@ -59,7 +59,7 @@ token = "yet another super secret token"
 			name:   "no header",
 			args:   []string{"-o=noheader"},
 			config: testConfig,
-			expOut: `*   my-context
+			expOut: `*   my-context      
     my-other-context
     my-third-context
 `,
@@ -68,7 +68,7 @@ token = "yet another super secret token"
 			name:   "no header only name",
 			args:   []string{"-o=noheader", "-o=columns=name"},
 			config: testConfig,
-			expOut: `my-context
+			expOut: `my-context      
 my-other-context
 my-third-context
 `,
@@ -83,8 +83,8 @@ my-third-context
 			postRun: func() {
 				_ = os.Unsetenv("HCLOUD_CONTEXT")
 			},
-			expOut: `ACTIVE   NAME
-         my-context
+			expOut: `ACTIVE   NAME            
+         my-context      
 *        my-other-context
          my-third-context
 `,
diff --git a/internal/cmd/datacenter/list_test.go b/internal/cmd/datacenter/list_test.go
index 5afa1c1..6c4ded2 100644
--- a/internal/cmd/datacenter/list_test.go
+++ b/internal/cmd/datacenter/list_test.go
@@ -42,7 +42,7 @@ func TestList(t *testing.T) {
 	out, errOut, err := fx.Run(cmd, []string{})
 
 	expOut := `ID   NAME        DESCRIPTION                   LOCATION
-4    fsn1-dc14   Falkenstein 1 virtual DC 14   fsn1
+4    fsn1-dc14   Falkenstein 1 virtual DC 14   fsn1    
 `
 
 	require.NoError(t, err)
diff --git a/internal/cmd/firewall/list_test.go b/internal/cmd/firewall/list_test.go
index 516b0d2..1a67215 100644
--- a/internal/cmd/firewall/list_test.go
+++ b/internal/cmd/firewall/list_test.go
@@ -43,7 +43,7 @@ func TestList(t *testing.T) {
 
 	out, errOut, err := fx.Run(cmd, []string{})
 
-	expOut := `ID    NAME   RULES COUNT   APPLIED TO COUNT
+	expOut := `ID    NAME   RULES COUNT   APPLIED TO COUNT             
 123   test   5 Rules       2 Servers | 0 Label Selectors
 `
 
diff --git a/internal/cmd/image/list_test.go b/internal/cmd/image/list_test.go
index 8a7ccba..5c89d68 100644
--- a/internal/cmd/image/list_test.go
+++ b/internal/cmd/image/list_test.go
@@ -46,7 +46,7 @@ func TestList(t *testing.T) {
 	out, errOut, err := fx.Run(cmd, []string{})
 
 	expOut := `ID    TYPE     NAME   DESCRIPTION   ARCHITECTURE   IMAGE SIZE   DISK SIZE   CREATED                        DEPRECATED
-123   system   test   -             x86            20.00 GB     15 GB       Wed Aug 20 12:00:00 UTC 2036   -
+123   system   test   -             x86            20.00 GB     15 GB       Wed Aug 20 12:00:00 UTC 2036   -         
 `
 
 	require.NoError(t, err)
diff --git a/internal/cmd/iso/list_test.go b/internal/cmd/iso/list_test.go
index 86bbbc2..70994fd 100644
--- a/internal/cmd/iso/list_test.go
+++ b/internal/cmd/iso/list_test.go
@@ -43,7 +43,7 @@ func TestList(t *testing.T) {
 	out, errOut, err := fx.Run(cmd, []string{})
 
 	expOut := `ID    NAME   DESCRIPTION   TYPE     ARCHITECTURE
-123   test   Test ISO      public   x86
+123   test   Test ISO      public   x86         
 `
 
 	require.NoError(t, err)
diff --git a/internal/cmd/loadbalancertype/list_test.go b/internal/cmd/loadbalancertype/list_test.go
index 48477e0..6d231cc 100644
--- a/internal/cmd/loadbalancertype/list_test.go
+++ b/internal/cmd/loadbalancertype/list_test.go
@@ -43,7 +43,7 @@ func TestList(t *testing.T) {
 	out, errOut, err := fx.Run(cmd, []string{})
 
 	expOut := `ID    NAME   DESCRIPTION   MAX SERVICES   MAX CONNECTIONS   MAX TARGETS
-123   test   -             12             100               5
+123   test   -             12             100               5          
 `
 
 	require.NoError(t, err)
diff --git a/internal/cmd/location/list_test.go b/internal/cmd/location/list_test.go
index 04e66cf..ed13d83 100644
--- a/internal/cmd/location/list_test.go
+++ b/internal/cmd/location/list_test.go
@@ -42,7 +42,7 @@ func TestList(t *testing.T) {
 
 	out, errOut, err := fx.Run(cmd, []string{})
 
-	expOut := `ID   NAME   DESCRIPTION   NETWORK ZONE   COUNTRY   CITY
+	expOut := `ID   NAME   DESCRIPTION   NETWORK ZONE   COUNTRY   CITY       
 1    fsn1   -             eu-central     DE        Falkenstein
 `
 
diff --git a/internal/cmd/output/output_test.go b/internal/cmd/output/output_test.go
index c030405..9b9c535 100644
--- a/internal/cmd/output/output_test.go
+++ b/internal/cmd/output/output_test.go
@@ -3,16 +3,21 @@ package output
 import (
 	"bytes"
 	"io"
-	"strings"
 	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
-type writerFlusherStub struct {
+type writerStub struct {
 	bytes.Buffer
 }
 
-func (s writerFlusherStub) Flush() error {
-	return nil
+func reset(to *Table, ws *writerStub) {
+	to.w.ResetHeaders()
+	to.w.ResetRows()
+	to.w.ResetFooters()
+	ws.Reset()
 }
 
 type testFieldsStruct struct {
@@ -21,52 +26,56 @@ type testFieldsStruct struct {
 }
 
 func TestTableOutput(t *testing.T) {
-	var wfs writerFlusherStub
+	var ws writerStub
 	to := NewTable(io.Discard)
-	to.w = &wfs
+	to.out = &ws
 
 	t.Run("AddAllowedFields", func(t *testing.T) {
 		to.AddAllowedFields(testFieldsStruct{})
-		if _, ok := to.allowedFields["name"]; !ok {
-			t.Error("name should be a allowed field")
-		}
+
+		assert.Contains(t, to.allowedFields, "name")
 	})
+
 	t.Run("AddFieldAlias", func(t *testing.T) {
 		to.AddFieldAlias("leeroy_jenkins", "leeroy jenkins")
-		if alias, ok := to.fieldAlias["leeroy_jenkins"]; !ok || alias != "leeroy jenkins" {
-			t.Errorf("leeroy_jenkins alias should be 'leeroy jenkins', is: %v", alias)
-		}
+
+		assert.Contains(t, to.fieldAlias, "leeroy_jenkins")
+		assert.Equal(t, "leeroy jenkins", to.fieldAlias["leeroy_jenkins"])
 	})
+
 	t.Run("AddFieldOutputFn", func(t *testing.T) {
-		to.AddFieldFn("leeroy jenkins", FieldFn(func(interface{}) string {
+		to.AddFieldFn("leeroy jenkins", func(interface{}) string {
 			return "LEEROY JENKINS!!!"
-		}))
-		if _, ok := to.fieldMapping["leeroy jenkins"]; !ok {
-			t.Errorf("'leeroy jenkins' field output fn should be set")
-		}
+		})
+
+		assert.Contains(t, to.fieldMapping, "leeroy jenkins")
 	})
+
 	t.Run("ValidateColumns", func(t *testing.T) {
 		err := to.ValidateColumns([]string{"non-existent", "NAME"})
-		if err == nil ||
-			strings.Contains(err.Error(), "name") ||
-			!strings.Contains(err.Error(), "non-existent") {
-			t.Errorf("error should contain 'non-existent' but not 'name': %v", err)
-		}
+
+		require.ErrorContains(t, err, "non-existent")
+		assert.NotContains(t, err.Error(), "name")
 	})
+
 	t.Run("WriteHeader", func(t *testing.T) {
 		to.WriteHeader([]string{"leeroy_jenkins", "name"})
-		if wfs.String() != "LEEROY JENKINS\tNAME\n" {
-			t.Errorf("written header should be 'LEEROY JENKINS\\tNAME\\n', is: %q", wfs.String())
-		}
-		wfs.Reset()
+		_ = to.Flush()
+
+		assert.Equal(t, "LEEROY JENKINS   NAME\n", ws.String())
+
+		reset(to, &ws)
 	})
+
 	t.Run("WriteLine", func(t *testing.T) {
 		to.Write([]string{"leeroy_jenkins", "name", "number"}, &testFieldsStruct{"test123", 1000000000})
-		if wfs.String() != "LEEROY JENKINS!!!\ttest123\t1000000000\n" {
-			t.Errorf("written line should be 'LEEROY JENKINS!!!\\ttest123\\t1000000000\\n', is: %q", wfs.String())
-		}
-		wfs.Reset()
+		_ = to.Flush()
+
+		assert.Equal(t, "LEEROY JENKINS!!!   test123   1000000000\n", ws.String())
+
+		reset(to, &ws)
 	})
+
 	t.Run("Columns", func(t *testing.T) {
 		if len(to.Columns()) != 3 {
 			t.Errorf("unexpected number of columns: %v", to.Columns())
diff --git a/internal/cmd/servertype/list_test.go b/internal/cmd/servertype/list_test.go
index 52ca93c..00d9b5d 100644
--- a/internal/cmd/servertype/list_test.go
+++ b/internal/cmd/servertype/list_test.go
@@ -46,7 +46,7 @@ func TestList(t *testing.T) {
 	out, errOut, err := fx.Run(cmd, []string{})
 
 	expOut := `ID    NAME   CORES   CPU TYPE   ARCHITECTURE   MEMORY   DISK    STORAGE TYPE
-123   test   2       shared     arm            8.0 GB   80 GB   local
+123   test   2       shared     arm            8.0 GB   80 GB   local       
 `
 
 	require.NoError(t, err)
@@ -90,9 +90,9 @@ func TestListColumnDeprecated(t *testing.T) {
 
 	out, errOut, err := fx.Run(cmd, []string{"-o=columns=id,name,deprecated"})
 
-	expOut := `ID    NAME         DEPRECATED
+	expOut := `ID    NAME         DEPRECATED                  
 123   deprecated   Thu Aug 20 12:00:00 UTC 2037
-124   current      -
+124   current      -                           
 `
 
 	require.NoError(t, err)
diff --git a/internal/cmd/sshkey/list_test.go b/internal/cmd/sshkey/list_test.go
index 28371f1..e1308c7 100644
--- a/internal/cmd/sshkey/list_test.go
+++ b/internal/cmd/sshkey/list_test.go
@@ -41,7 +41,7 @@ func TestList(t *testing.T) {
 	out, errOut, err := fx.Run(cmd, []string{})
 
 	expOut := `ID    NAME   FINGERPRINT   AGE
-123   test   -             1h
+123   test   -             1h 
 `
 
 	require.NoError(t, err)
diff --git a/internal/cmd/volume/list_test.go b/internal/cmd/volume/list_test.go
index d1f404b..f5d67a5 100644
--- a/internal/cmd/volume/list_test.go
+++ b/internal/cmd/volume/list_test.go
@@ -47,7 +47,7 @@ func TestList(t *testing.T) {
 	out, errOut, err := fx.Run(cmd, []string{})
 
 	expOut := `ID    NAME   SIZE    SERVER     LOCATION   AGE
-123   test   50 GB   myServer   fsn1       1h
+123   test   50 GB   myServer   fsn1       1h 
 `
 
 	require.NoError(t, err)
diff --git a/internal/testutil/cmd.go b/internal/testutil/cmd.go
index 0746ce5..ea8964b 100644
--- a/internal/testutil/cmd.go
+++ b/internal/testutil/cmd.go
@@ -35,6 +35,8 @@ const (
 )
 
 func (dt DataType) test(t *testing.T, expected string, actual string, _ ...any) {
+	t.Helper()
+
 	switch dt {
 	case DataTypeJSON:
 		assert.JSONEq(t, expected, actual)
diff --git a/test/e2e/certificate_test.go b/test/e2e/certificate_test.go
index 2c1fa19..3bdecf5 100644
--- a/test/e2e/certificate_test.go
+++ b/test/e2e/certificate_test.go
@@ -120,7 +120,7 @@ func runCertificateTestSuite(t *testing.T, certName string, certID int64, certTy
 				Raw(fingerprintRegex).Whitespace().
 				OneOf("completed", "n/a").Whitespace().
 				Lit("n/a").Whitespace().
-				Age().Newline().
+				Age().OptionalWhitespace().Newline().
 				End(),
 			out,
 		)
diff --git a/test/e2e/datacenter_test.go b/test/e2e/datacenter_test.go
index c5d631f..abe9bac 100644
--- a/test/e2e/datacenter_test.go
+++ b/test/e2e/datacenter_test.go
@@ -19,8 +19,8 @@ func TestDatacenter(t *testing.T) {
 			require.NoError(t, err)
 			assert.Regexp(t,
 				NewRegex().Start().
-					SeparatedByWhitespace("ID", "NAME", "DESCRIPTION", "LOCATION").Newline().
-					AnyTimes(NewRegex().Int().Whitespace().Identifier().Whitespace().AnyString().Whitespace().LocationName().Newline()).
+					SeparatedByWhitespace("ID", "NAME", "DESCRIPTION", "LOCATION").OptionalWhitespace().Newline().
+					AnyTimes(NewRegex().Int().Whitespace().Identifier().Whitespace().AnyString().Whitespace().LocationName().OptionalWhitespace().Newline()).
 					End(),
 				out,
 			)
diff --git a/test/e2e/floatingip_test.go b/test/e2e/floatingip_test.go
index c65ce88..f823629 100644
--- a/test/e2e/floatingip_test.go
+++ b/test/e2e/floatingip_test.go
@@ -157,7 +157,7 @@ func TestFloatingIP(t *testing.T) {
 						Lit("delete").Whitespace().
 						Lit("foo=bar").Whitespace().
 						UnixDate().Whitespace().
-						Age().Newline().
+						Age().OptionalWhitespace().Newline().
 						End(),
 					out,
 				)
@@ -300,8 +300,8 @@ func TestFloatingIP(t *testing.T) {
 			require.NoError(t, err)
 			assert.Regexp(t,
 				NewRegex().Start().
-					SeparatedByWhitespace("IP", "DNS").Newline().
-					Lit(ipStr).Lit("/64").Whitespace().Lit("2 entries").Newline().
+					SeparatedByWhitespace("IP", "DNS").OptionalWhitespace().Newline().
+					Lit(ipStr).Lit("/64").Whitespace().Lit("2 entries").OptionalWhitespace().Newline().
 					End(),
 				out,
 			)
diff --git a/test/e2e/iso_test.go b/test/e2e/iso_test.go
index cd55d09..2a592e1 100644
--- a/test/e2e/iso_test.go
+++ b/test/e2e/iso_test.go
@@ -29,7 +29,7 @@ func TestISO(t *testing.T) {
 						Identifier().Whitespace().
 						AnyString().Whitespace().
 						OneOfLit("public", "private").Whitespace().
-						OneOf("arm", "x86").Newline()).
+						OneOf("arm", "x86").OptionalWhitespace().Newline()).
 					End(),
 				out,
 			)
diff --git a/test/e2e/load_balancer_type_test.go b/test/e2e/load_balancer_type_test.go
index 6eed4ec..11346c8 100644
--- a/test/e2e/load_balancer_type_test.go
+++ b/test/e2e/load_balancer_type_test.go
@@ -30,7 +30,7 @@ func TestLoadBalancerType(t *testing.T) {
 						AnyString().Whitespace().
 						Int().Whitespace().
 						Int().Whitespace().
-						Int().Newline()).
+						Int().OptionalWhitespace().Newline()).
 					End(),
 				out,
 			)
diff --git a/test/e2e/network_test.go b/test/e2e/network_test.go
index a7e9b98..e9c3b21 100644
--- a/test/e2e/network_test.go
+++ b/test/e2e/network_test.go
@@ -55,12 +55,12 @@ func TestNetwork(t *testing.T) {
 		require.NoError(t, err)
 		assert.Regexp(t,
 			NewRegex().Start().
-				SeparatedByWhitespace("SERVERS", "IP", "RANGE", "LABELS", "PROTECTION", "CREATED", "AGE").Newline().
+				SeparatedByWhitespace("SERVERS", "IP", "RANGE", "LABELS", "PROTECTION", "CREATED", "AGE").OptionalWhitespace().Newline().
 				Lit("0 servers").Whitespace().
 				Lit("10.0.0.0/24").Whitespace().
 				Lit("delete").Whitespace().
 				UnixDate().Whitespace().
-				Age().Newline().End(),
+				Age().OptionalWhitespace().Newline().End(),
 			out,
 		)
 	})
diff --git a/test/e2e/placement_group_test.go b/test/e2e/placement_group_test.go
index 7d52511..01bbfdf 100644
--- a/test/e2e/placement_group_test.go
+++ b/test/e2e/placement_group_test.go
@@ -62,13 +62,13 @@ func TestPlacementGroup(t *testing.T) {
 
 			assert.Regexp(t,
 				NewRegex().Start().
-					SeparatedByWhitespace("ID", "NAME", "SERVERS", "TYPE", "CREATED", "AGE").Newline().
+					SeparatedByWhitespace("ID", "NAME", "SERVERS", "TYPE", "CREATED", "AGE").OptionalWhitespace().Newline().
 					Int().Whitespace().
 					Raw(`new-test-placement-group-[0-9a-f]{8}`).Whitespace().
 					Lit("0 servers").Whitespace().
 					Lit("spread").Whitespace().
 					UnixDate().Whitespace().
-					Age().Newline().End(),
+					Age().OptionalWhitespace().Newline().End(),
 				out,
 			)
 		})
diff --git a/test/e2e/sshkey_test.go b/test/e2e/sshkey_test.go
index 1fae1fb..6611d57 100644
--- a/test/e2e/sshkey_test.go
+++ b/test/e2e/sshkey_test.go
@@ -6,6 +6,7 @@ import (
 	"context"
 	"fmt"
 	"strconv"
+	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -52,14 +53,14 @@ func TestSSHKey(t *testing.T) {
 			require.NoError(t, err)
 			assert.Regexp(t,
 				NewRegex().Start().
-					SeparatedByWhitespace("ID", "NAME", "FINGERPRINT", "PUBLIC KEY", "LABELS", "CREATED", "AGE").Newline().
+					SeparatedByWhitespace("ID", "NAME", "FINGERPRINT", "PUBLIC KEY", "LABELS", "CREATED", "AGE").OptionalWhitespace().Newline().
 					Lit(strconv.FormatInt(sshKeyID, 10)).Whitespace().
 					Lit(sshKeyName).Whitespace().
 					Lit(fingerprint).Whitespace().
 					Lit(pubKey).Whitespace().
 					Lit("baz=qux, foo=bar").Whitespace().
 					UnixDate().Whitespace().
-					Age().Newline().
+					Age().OptionalWhitespace().Newline().
 					End(),
 				out,
 			)
@@ -106,7 +107,7 @@ func TestSSHKey(t *testing.T) {
 			Lit("Name:").Whitespace().Lit(sshKeyName).Newline().
 			Lit("Created:").Whitespace().UnixDate().Lit(" (").HumanizeTime().Lit(")").Newline().
 			Lit("Fingerprint:").Whitespace().Lit(fingerprint).Newline().
-			Lit("Public Key:").Newline().Lit(pubKey).
+			Lit("Public Key:").Newline().Lit(pubKey).Newline().
 			Lit("Labels:").Newline().
 			Lit("  foo:").Whitespace().Lit("bar").Newline().
 			End(),
@@ -160,5 +161,5 @@ func generateSSHKey() (string, string, error) {
 		return "", "", err
 	}
 
-	return string(pub), fingerprint, nil
+	return strings.TrimSpace(string(pub)), fingerprint, nil
 }
diff --git a/test/e2e/util.go b/test/e2e/util.go
index 510c597..84a36ae 100644
--- a/test/e2e/util.go
+++ b/test/e2e/util.go
@@ -101,6 +101,10 @@ func (r RegexBuilder) Whitespace() RegexBuilder {
 	return r.Raw(`\s+`)
 }
 
+func (r RegexBuilder) OptionalWhitespace() RegexBuilder {
+	return r.Raw(`\s*`)
+}
+
 func (r RegexBuilder) Newline() RegexBuilder {
 	return r.Raw("\n")
 }