mirror of https://github.com/hashicorp/boundary
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.mdpull/3251/head
parent
916a919bf0
commit
ed6882a457
@ -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…
Reference in new issue