From ed6882a457b9a42ff41d602e397b43117ee318ad Mon Sep 17 00:00:00 2001 From: Timothy Messier Date: Wed, 10 May 2023 19:12:02 +0000 Subject: [PATCH] feat(bsr/convert): Add asciicast package This provides a new internal package for constructing and serializing an asciicast in the v2 asciicast file fromat. See: https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md --- .../convert/internal/asciicast/asciicast.go | 135 ++++++++++++++ .../internal/asciicast/asciicast_test.go | 169 ++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 internal/bsr/convert/internal/asciicast/asciicast.go create mode 100644 internal/bsr/convert/internal/asciicast/asciicast_test.go diff --git a/internal/bsr/convert/internal/asciicast/asciicast.go b/internal/bsr/convert/internal/asciicast/asciicast.go new file mode 100644 index 0000000000..e38c17a2bc --- /dev/null +++ b/internal/bsr/convert/internal/asciicast/asciicast.go @@ -0,0 +1,135 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Package asciicast defines structs to ease the creation of asciicast files. +// See: https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md +package asciicast + +import ( + "encoding/json" + "fmt" + "strconv" + "time" + + "github.com/hashicorp/boundary/internal/bsr" +) + +const ( + // Version is the file format version. + Version uint32 = 2 +) + +// Minimums for width and height to always display a reasonable terminal. +// This is only the initial terminal size, so it does not seem to have any +// real impact on playback. +const ( + MinWidth uint32 = 80 + MinHeight uint32 = 120 +) + +// Sane defaults for the Env section of the Header. +const ( + DefaultShell = "/bin/bash" + DefaultTerm = "xterm" +) + +// Time is a time.Time that will be marshaled to a unix timestamp as an integer. +type Time time.Time + +// MarshalJSON implements the Marshaler interface. +// It will represent the time as a unix timestamp integer. +func (t Time) MarshalJSON() ([]byte, error) { + return []byte(strconv.FormatInt(time.Time(t).Unix(), 10)), nil +} + +func (t *Time) UnmarshalJSON(b []byte) (err error) { + q, err := strconv.ParseInt(string(b), 10, 64) + if err != nil { + return err + } + *(*time.Time)(t) = time.Unix(q, 0) + return +} + +// HeaderEnv is the env section of the header line. +type HeaderEnv struct { + Shell string `json:"SHELL,omitempty"` + Term string `json:"TERM,omitempty"` +} + +// Header is the first line of an asciicast. +// https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md#header +type Header struct { + Version uint32 `json:"version"` + Width uint32 `json:"width"` + Height uint32 `json:"height"` + Timestamp Time `json:"timestamp"` + Env HeaderEnv `json:"env"` +} + +// NewHeader creates a Header. +func NewHeader() *Header { + return &Header{ + Version: Version, + Width: MinWidth, + Height: MinHeight, + Env: HeaderEnv{ + Shell: DefaultShell, + Term: DefaultTerm, + }, + } +} + +// EventType defines the type of an event in the event stream. +// https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md#supported-event-types +type EventType string + +// Valid event types +// https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md#supported-event-types +const ( + Output EventType = `o` + Input EventType = `i` + Marker EventType = `m` +) + +// ValidEventType checks if a given EventType is valid. +func ValidEventType(t EventType) bool { + switch t { + case Output, Input, Marker: + return true + } + return false +} + +// Event is an element in the event stream. +// https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md#event-stream +type Event struct { + Time float64 + Type EventType + Data []byte +} + +// NewEvent creates an Event for the event stream. +func NewEvent(t EventType, ts float64, data []byte) (*Event, error) { + const op = "asciicast.NewEvent" + + if !ValidEventType(t) { + return nil, fmt.Errorf("%s: invalid event type %s: %w", op, t, bsr.ErrInvalidParameter) + } + + return &Event{ + Time: ts, + Type: t, + Data: data, + }, nil +} + +// MarshalJSON implements the Marshaler interface. +func (e *Event) MarshalJSON() ([]byte, error) { + line := []any{ + e.Time, + string(e.Type), + string(e.Data), + } + return json.Marshal(line) +} diff --git a/internal/bsr/convert/internal/asciicast/asciicast_test.go b/internal/bsr/convert/internal/asciicast/asciicast_test.go new file mode 100644 index 0000000000..ba0dabf27c --- /dev/null +++ b/internal/bsr/convert/internal/asciicast/asciicast_test.go @@ -0,0 +1,169 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package asciicast_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/hashicorp/boundary/internal/bsr/convert/internal/asciicast" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidEventType(t *testing.T) { + cases := []struct { + name string + in asciicast.EventType + want bool + }{ + { + string(asciicast.Output), + asciicast.Output, + true, + }, + { + string(asciicast.Input), + asciicast.Input, + true, + }, + { + string(asciicast.Marker), + asciicast.Marker, + true, + }, + { + "invalid", + asciicast.EventType("invalid"), + false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := asciicast.ValidEventType(tc.in) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestHeaderMarshal(t *testing.T) { + cases := []struct { + name string + h *asciicast.Header + want []byte + wantErr error + }{ + { + "default", + asciicast.NewHeader(), + []byte(`{"version":2,"width":80,"height":120,"timestamp":-62135596800,"env":{"SHELL":"/bin/bash","TERM":"xterm"}}`), + nil, + }, + { + "layout-time", // https://pkg.go.dev/time#pkg-constants + &asciicast.Header{ + Version: asciicast.Version, + Width: 160, + Height: 200, + Timestamp: asciicast.Time(func() time.Time { t, _ := time.Parse(time.Layout, time.Layout); return t }()), + Env: asciicast.HeaderEnv{ + Shell: "/bin/dash", + Term: "st-256color", + }, + }, + []byte(`{"version":2,"width":160,"height":200,"timestamp":1136239445,"env":{"SHELL":"/bin/dash","TERM":"st-256color"}}`), + nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := json.Marshal(tc.h) + if tc.wantErr != nil { + require.EqualError(t, err, tc.wantErr.Error()) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func mustEvent(t *testing.T, ty asciicast.EventType, ts float64, data []byte) *asciicast.Event { + e, err := asciicast.NewEvent(ty, ts, data) + require.NoError(t, err) + return e +} + +func TestEventMarshal(t *testing.T) { + cases := []struct { + name string + e *asciicast.Event + want []byte + wantErr error + }{ + { + "echo", + mustEvent(t, asciicast.Output, 0.1, []byte("echo")), + []byte(`[0.1,"o","echo"]`), + nil, + }, + { + "echo-input", + mustEvent(t, asciicast.Input, 0.1, []byte("echo")), + []byte(`[0.1,"i","echo"]`), + nil, + }, + { + "echo-marker", + mustEvent(t, asciicast.Marker, 0.1, []byte("echo")), + []byte(`[0.1,"m","echo"]`), + nil, + }, + { + "time-does-not-round", + mustEvent(t, asciicast.Output, 0.9999999, []byte("echo")), + []byte(`[0.9999999,"o","echo"]`), + nil, + }, + { + "prompt", + mustEvent(t, asciicast.Output, 0.1, []byte("\x1b[?2004hlocalhost:~$")), + []byte(`[0.1,"o","\u001b[?2004hlocalhost:~$"]`), + nil, + }, + { + "new-line", + mustEvent(t, asciicast.Output, 0.1, []byte("\r\n\x1b[?2004l\r")), + []byte(`[0.1,"o","\r\n\u001b[?2004l\r"]`), + nil, + }, + { + "backspace", + mustEvent(t, asciicast.Output, 0.1, []byte("\b\x1b[K")), + []byte(`[0.1,"o","\u0008\u001b[K"]`), + nil, + }, + { + "tab", + mustEvent(t, asciicast.Output, 0.1, []byte("\a")), + []byte(`[0.1,"o","\u0007"]`), + nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := json.Marshal(tc.e) + if tc.wantErr != nil { + require.EqualError(t, err, tc.wantErr.Error()) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +}