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