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
pull/3251/head
Timothy Messier 3 years ago
parent 916a919bf0
commit ed6882a457
No known key found for this signature in database
GPG Key ID: EFD2F184F7600572

@ -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)
}

@ -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)
})
}
}
Loading…
Cancel
Save