Files
wehub-resource-sync 95f0813436
build / build (push) Failing after 0s
nightly / nightly (push) Failing after 0s
build / snapshot (push) Failing after 0s
lint / lint (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 13:02:37 +08:00

484 lines
14 KiB
Go

package parser
import (
"os"
"strings"
"testing"
"github.com/charmbracelet/vhs/lexer"
"github.com/charmbracelet/vhs/token"
)
func TestParser(t *testing.T) {
input := `
Set TypingSpeed 100ms
Set WaitTimeout 1m
Set WaitPattern /foo/
Type "echo 'Hello, World!'"
Enter
Backspace@0.1 5
Backspace@.1 5
Backspace@1 5
Backspace@100ms 5
Delete 2
Insert 2
Right 3
Left 3
Up@50ms
Down 2
ScrollUp 4
ScrollDown@100ms 2
Ctrl+C
Ctrl+L
Alt+.
Sleep 100ms
Sleep 3
Wait
Wait+Screen
Wait@100ms /foobar/`
expected := []Command{
{Type: token.SET, Options: "TypingSpeed", Args: "100ms"},
{Type: token.SET, Options: "WaitTimeout", Args: "1m"},
{Type: token.SET, Options: "WaitPattern", Args: "foo"},
{Type: token.TYPE, Options: "", Args: "echo 'Hello, World!'"},
{Type: token.ENTER, Options: "", Args: "1"},
{Type: token.BACKSPACE, Options: "0.1s", Args: "5"},
{Type: token.BACKSPACE, Options: ".1s", Args: "5"},
{Type: token.BACKSPACE, Options: "1s", Args: "5"},
{Type: token.BACKSPACE, Options: "100ms", Args: "5"},
{Type: token.DELETE, Options: "", Args: "2"},
{Type: token.INSERT, Options: "", Args: "2"},
{Type: token.RIGHT, Options: "", Args: "3"},
{Type: token.LEFT, Options: "", Args: "3"},
{Type: token.UP, Options: "50ms", Args: "1"},
{Type: token.DOWN, Options: "", Args: "2"},
{Type: token.SCROLL_UP, Options: "", Args: "4"},
{Type: token.SCROLL_DOWN, Options: "100ms", Args: "2"},
{Type: token.CTRL, Options: "", Args: "C"},
{Type: token.CTRL, Options: "", Args: "L"},
{Type: token.ALT, Options: "", Args: "."},
{Type: token.SLEEP, Args: "100ms"},
{Type: token.SLEEP, Args: "3s"},
{Type: token.WAIT, Args: "Line"},
{Type: token.WAIT, Args: "Screen"},
{Type: token.WAIT, Options: "100ms", Args: "Line foobar"},
}
l := lexer.New(input)
p := New(l)
cmds := p.Parse()
if len(cmds) != len(expected) {
t.Fatalf("Expected %d commands, got %d; %v", len(expected), len(cmds), cmds)
}
for i, cmd := range cmds {
if cmd.Type != expected[i].Type {
t.Errorf("Expected command %d to be %s, got %s", i, expected[i].Type, cmd.Type)
}
if cmd.Args != expected[i].Args {
t.Errorf("Expected command %d to have args %s, got %s", i, expected[i].Args, cmd.Args)
}
if cmd.Options != expected[i].Options {
t.Errorf("Expected command %d to have options %s, got %s", i, expected[i].Options, cmd.Options)
}
}
}
func TestParserErrors(t *testing.T) {
input := `
Type Enter
Type "echo 'Hello, World!'" Enter
Foo
Sleep Bar`
l := lexer.New(input)
p := New(l)
_ = p.Parse()
expectedErrors := []string{
" 2:6 │ Type expects string",
" 4:1 │ Invalid command: Foo",
" 5:1 │ Expected time after Sleep",
" 5:7 │ Invalid command: Bar",
}
if len(p.errors) != len(expectedErrors) {
t.Fatalf("Expected %d errors, got %d", len(expectedErrors), len(p.errors))
}
for i, err := range p.errors {
if err.String() != expectedErrors[i] {
t.Errorf("Expected error %d to be [%s], got (%s)", i, expectedErrors[i], err)
}
}
}
func TestParseTapeFile(t *testing.T) {
input, err := os.ReadFile("../examples/fixtures/all.tape")
if err != nil {
t.Fatal("could not read fixture file")
}
expected := []Command{
{Type: token.OUTPUT, Options: ".gif", Args: "examples/fixtures/all.gif"},
{Type: token.OUTPUT, Options: ".mp4", Args: "examples/fixtures/all.mp4"},
{Type: token.OUTPUT, Options: ".webm", Args: "examples/fixtures/all.webm"},
{Type: token.SET, Options: "Shell", Args: "fish"},
{Type: token.SET, Options: "FontSize", Args: "22"},
{Type: token.SET, Options: "FontFamily", Args: "DejaVu Sans Mono"},
{Type: token.SET, Options: "Height", Args: "600"},
{Type: token.SET, Options: "Width", Args: "1200"},
{Type: token.SET, Options: "LetterSpacing", Args: "1"},
{Type: token.SET, Options: "LineHeight", Args: "1.2"},
{Type: token.SET, Options: "Theme", Args: "{ \"name\": \"Whimsy\", \"black\": \"#535178\", \"red\": \"#ef6487\", \"green\": \"#5eca89\", \"yellow\": \"#fdd877\", \"blue\": \"#65aef7\", \"purple\": \"#aa7ff0\", \"cyan\": \"#43c1be\", \"white\": \"#ffffff\", \"brightBlack\": \"#535178\", \"brightRed\": \"#ef6487\", \"brightGreen\": \"#5eca89\", \"brightYellow\": \"#fdd877\", \"brightBlue\": \"#65aef7\", \"brightPurple\": \"#aa7ff0\", \"brightCyan\": \"#43c1be\", \"brightWhite\": \"#ffffff\", \"background\": \"#29283b\", \"foreground\": \"#b3b0d6\", \"selectionBackground\": \"#3d3c58\", \"cursorColor\": \"#b3b0d6\" }"},
{Type: token.SET, Options: "Theme", Args: "Catppuccin Mocha"},
{Type: token.SET, Options: "Padding", Args: "50"},
{Type: token.SET, Options: "Framerate", Args: "60"},
{Type: token.SET, Options: "PlaybackSpeed", Args: "2"},
{Type: token.SET, Options: "TypingSpeed", Args: ".1s"},
{Type: token.SET, Options: "LoopOffset", Args: "60.4%"},
{Type: token.SET, Options: "LoopOffset", Args: "20.99%"},
{Type: token.SET, Options: "CursorBlink", Args: "false"},
{Type: token.SLEEP, Options: "", Args: "1s"},
{Type: token.SLEEP, Options: "", Args: "500ms"},
{Type: token.SLEEP, Options: "", Args: ".5s"},
{Type: token.SLEEP, Options: "", Args: "0.5s"},
{Type: token.TYPE, Options: ".5s", Args: "All"},
{Type: token.TYPE, Options: "500ms", Args: "All"},
{Type: token.TYPE, Options: "", Args: "Double Quote"},
{Type: token.TYPE, Options: "", Args: "\"Single\" Quote"},
{Type: token.TYPE, Options: "", Args: `"Backtick" 'Quote'`},
{Type: token.BACKSPACE, Options: "", Args: "1"},
{Type: token.BACKSPACE, Options: "", Args: "2"},
{Type: token.BACKSPACE, Options: "1s", Args: "3"},
{Type: token.DELETE, Options: "", Args: "1"},
{Type: token.DELETE, Options: "", Args: "2"},
{Type: token.DELETE, Options: "1s", Args: "3"},
{Type: token.INSERT, Options: "", Args: "1"},
{Type: token.INSERT, Options: "", Args: "2"},
{Type: token.INSERT, Options: "1s", Args: "3"},
{Type: token.DOWN, Options: "", Args: "1"},
{Type: token.DOWN, Options: "", Args: "2"},
{Type: token.DOWN, Options: "1s", Args: "3"},
{Type: token.PAGE_DOWN, Options: "", Args: "1"},
{Type: token.PAGE_DOWN, Options: "", Args: "2"},
{Type: token.PAGE_DOWN, Options: "1s", Args: "3"},
{Type: token.SCROLL_DOWN, Options: "", Args: "1"},
{Type: token.SCROLL_DOWN, Options: "", Args: "2"},
{Type: token.SCROLL_DOWN, Options: "1s", Args: "3"},
{Type: token.ENTER, Options: "", Args: "1"},
{Type: token.ENTER, Options: "", Args: "2"},
{Type: token.ENTER, Options: "1s", Args: "3"},
{Type: token.SPACE, Options: "", Args: "1"},
{Type: token.SPACE, Options: "", Args: "2"},
{Type: token.SPACE, Options: "1s", Args: "3"},
{Type: token.TAB, Options: "", Args: "1"},
{Type: token.TAB, Options: "", Args: "2"},
{Type: token.TAB, Options: "1s", Args: "3"},
{Type: token.LEFT, Options: "", Args: "1"},
{Type: token.LEFT, Options: "", Args: "2"},
{Type: token.LEFT, Options: "1s", Args: "3"},
{Type: token.RIGHT, Options: "", Args: "1"},
{Type: token.RIGHT, Options: "", Args: "2"},
{Type: token.RIGHT, Options: "1s", Args: "3"},
{Type: token.UP, Options: "", Args: "1"},
{Type: token.UP, Options: "", Args: "2"},
{Type: token.UP, Options: "1s", Args: "3"},
{Type: token.PAGE_UP, Options: "", Args: "1"},
{Type: token.PAGE_UP, Options: "", Args: "2"},
{Type: token.PAGE_UP, Options: "1s", Args: "3"},
{Type: token.SCROLL_UP, Options: "", Args: "1"},
{Type: token.SCROLL_UP, Options: "", Args: "2"},
{Type: token.SCROLL_UP, Options: "1s", Args: "3"},
{Type: token.DOWN, Options: "", Args: "1"},
{Type: token.DOWN, Options: "", Args: "2"},
{Type: token.DOWN, Options: "1s", Args: "3"},
{Type: token.CTRL, Options: "", Args: "C"},
{Type: token.CTRL, Options: "", Args: "L"},
{Type: token.CTRL, Options: "", Args: "R"},
{Type: token.ALT, Options: "", Args: "."},
{Type: token.ALT, Options: "", Args: "L"},
{Type: token.ALT, Options: "", Args: "i"},
{Type: token.HIDE, Options: "", Args: ""},
{Type: token.SHOW, Options: "", Args: ""},
}
l := lexer.New(string(input))
p := New(l)
cmds := p.Parse()
if len(cmds) != len(expected) {
t.Fatalf("Expected %d commands, got %d", len(expected), len(cmds))
}
for i, cmd := range cmds {
if cmd.Type != expected[i].Type {
t.Errorf("Expected command %d to be %s, got %s", i, expected[i].Type, cmd.Type)
}
if cmd.Args != expected[i].Args {
t.Errorf("Expected command %d to have args %s, got %s", i, expected[i].Args, cmd.Args)
}
if cmd.Options != expected[i].Options {
t.Errorf("Expected command %d to have options %s, got %s", i, expected[i].Options, cmd.Options)
}
}
}
func TestParseCtrl(t *testing.T) {
tests := []struct {
name string
tape string
wantArgs []string
wantErr bool
}{
{
name: "should parse with multiple modifiers",
tape: "Ctrl+Shift+Alt+C",
wantArgs: []string{"Shift", "Alt", "C"},
wantErr: false,
},
{
name: "should not parse with out of order modifiers",
tape: "Ctrl+Shift+C+Alt",
wantErr: true,
},
{
name: "should not parse with out of order modifiers",
tape: "Ctrl+Shift+C+Alt+C",
wantErr: true,
},
{
name: "should parse Ctrl+Left",
tape: "Ctrl+Left",
wantArgs: []string{"Left"},
wantErr: false,
},
{
name: "should parse Ctrl+Right",
tape: "Ctrl+Right",
wantArgs: []string{"Right"},
wantErr: false,
},
{
name: "should parse Ctrl+Up",
tape: "Ctrl+Up",
wantArgs: []string{"Up"},
wantErr: false,
},
{
name: "should parse Ctrl+Down",
tape: "Ctrl+Down",
wantArgs: []string{"Down"},
wantErr: false,
},
{
name: "should parse Ctrl+Alt+Left",
tape: "Ctrl+Alt+Left",
wantArgs: []string{"Alt", "Left"},
wantErr: false,
},
{
name: "should parse Ctrl+Alt+Right",
tape: "Ctrl+Alt+Right",
wantArgs: []string{"Alt", "Right"},
wantErr: false,
},
{
name: "should parse Ctrl+Alt+Up",
tape: "Ctrl+Alt+Up",
wantArgs: []string{"Alt", "Up"},
wantErr: false,
},
{
name: "should parse Ctrl+Alt+Down",
tape: "Ctrl+Alt+Down",
wantArgs: []string{"Alt", "Down"},
wantErr: false,
},
{
name: "Ctrl+Backspace",
tape: "Ctrl+Backspace",
wantArgs: []string{"Backspace"},
wantErr: false,
},
{
name: "Ctrl+Space",
tape: "Ctrl+Space",
wantArgs: []string{"Space"},
wantErr: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
l := lexer.New(tc.tape)
p := New(l)
cmd := p.parseCtrl()
if tc.wantErr {
if len(p.errors) == 0 {
t.Errorf("Expected to parse with errors but was success")
}
return
}
if len(p.errors) > 0 {
t.Errorf("Expected to parse with no errors but was failure")
}
args := strings.Split(cmd.Args, " ")
if len(tc.wantArgs) != len(args) {
t.Fatalf("Unable to parse args, expected args %d, got %d", len(tc.wantArgs), len(args))
}
for i, arg := range args {
if tc.wantArgs[i] != arg {
t.Errorf("Arg %d is wrong, expected %s, got %s", i, tc.wantArgs[i], arg)
}
}
})
}
}
type parseSourceTest struct {
tape string
srcTape string
errors []string
writeFile bool
}
func (st *parseSourceTest) run(t *testing.T) {
if st.writeFile {
err := os.WriteFile("source.tape", []byte(st.srcTape), os.ModePerm)
if err != nil {
t.Fatal(err)
}
}
l := lexer.New(st.tape)
p := New(l)
_ = p.Parse()
if len(p.errors) != len(st.errors) {
t.Fatalf("Expected errors: %d, errors: %d", len(st.errors), len(p.errors))
}
for i := range st.errors {
err := p.errors[i].Msg
expected := st.errors[i]
if err != expected {
t.Errorf("Expected error: %s, actual error %s", expected, err)
}
}
os.Remove("source.tape")
}
func TestParseSource(t *testing.T) {
t.Run("should not return errors when tape exist and is NOT empty", func(t *testing.T) {
test := &parseSourceTest{
tape: "Source source.tape",
srcTape: `Type "echo 'Welcome to VHS!'"`,
writeFile: true,
}
test.run(t)
})
t.Run("should return errors when tape NOT found", func(t *testing.T) {
test := &parseSourceTest{
tape: "Source source.tape",
errors: []string{"File source.tape not found"},
writeFile: false,
}
test.run(t)
})
t.Run("should return error when tape extension is NOT (.tape)", func(t *testing.T) {
test := &parseSourceTest{
tape: "Source source.vhs",
errors: []string{"Expected file with .tape extension"},
writeFile: true,
}
test.run(t)
})
t.Run("should return error when Source command does NOT have tape path", func(t *testing.T) {
test := &parseSourceTest{
tape: "Source",
errors: []string{"Expected path after Source"},
writeFile: true,
}
test.run(t)
})
t.Run("should return error when find nested Source commands", func(t *testing.T) {
test := &parseSourceTest{
tape: "Source source.tape",
srcTape: `Type "echo 'Welcome to VHS!'"
Source magic.tape
Type "goodbye"
`,
errors: []string{"Nested Source detected"},
writeFile: true,
}
test.run(t)
})
}
type parseScreenshotTest struct {
tape string
errors []string
}
func (st *parseScreenshotTest) run(t *testing.T) {
l := lexer.New(st.tape)
p := New(l)
_ = p.Parse()
if len(p.errors) != len(st.errors) {
t.Fatalf("Expected errors: %d, errors: %d", len(st.errors), len(p.errors))
}
for i := range st.errors {
err := p.errors[i].Msg
expected := st.errors[i]
if err != expected {
t.Errorf("Expected error: %s, actual error %s", expected, err)
}
}
}
func TestParseScreeenshot(t *testing.T) {
t.Run("should return error when screenshot extension is NOT (.png)", func(t *testing.T) {
test := &parseScreenshotTest{
tape: "Screenshot step_one_screenshot.jpg",
errors: []string{"Expected file with .png extension"},
}
test.run(t)
})
t.Run("should return error when screenshot path is missing", func(t *testing.T) {
test := &parseScreenshotTest{
tape: "Screenshot",
errors: []string{"Expected path after Screenshot"},
}
test.run(t)
})
}