Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Wait to wait for expected output #257

Open
wants to merge 22 commits into
base: main
Choose a base branch
from

Conversation

mastercactapus
Copy link
Contributor

This PR adds two new commands:

  • MatchLine that takes a regular expression and waits until the current line matches
  • MatchScreen that takes a regular expression and waits until the "screen" matches

The purpose is to allow waiting for things like network or serial devices that might have more variable delays while using VHS.


MatchLine example
Output "out.gif"

Type "sudo apt update" Enter
MatchLine "password"

Type "hunter2" Enter # fake password :D
MatchLine "^>"

Type "sudo apt dist-upgrade" Enter
MatchLine "^>"

Sleep 1

Made with VHS

MatchScreen example
Output "out.gif"

Set Width 1280
Set Height 720

Type "telnet telehack.com" Enter
MatchScreen "command list"

Type "starwars" Enter
MatchScreen "A long time ago"

Ctrl+C
MatchLine "^\."

Type "exit" Enter
MatchLine "^>"

Sleep 1

Made with VHS

Additional Info

  • A few string(s[0]) + strings.ToLower(s[1:]) places were changed to a toCamel helper with unit test (fixes issue with MUTLI_WORD commands formatting)
  • A .Buffer() method was added to *VHS to allow getting the current buffer as text (screen)
  • A .CurrentLine() method was added to *VHS to allow getting the current line as text
  • .SaveOutput() was updated to use the shared method (largely the same code as before)
  • Possibly closes Wait for command to finish before continuing #70

@rbergmanaf
Copy link

I've been experimenting with this and it's a useful feature, thanks for creating this!

I've noticed though that it would benefit from a timeout option in the event the expected pattern isn't encountered?

@mastercactapus
Copy link
Contributor Author

mastercactapus commented May 24, 2023

@rbergmanaf Yeah, I could see using that; should it be optional or required? If optional, should it have a default? It seems odd to have something hang forever by default. Something short, like 3 seconds, and then you can manually raise it for long commands.

If the default is shorter, then it's more likely a user will put their value in for longer-running commands -- the last thing we want is a script that takes a long time failing halfway through because the user didn't realize there was a 30-second timeout or something.

MatchLine <regex> [timeout]

I considered using @ like Type (e.g., MatchLine@1s), but it doesn't read well.

Alternatively, since it is a waiting command, we could just make it mandatory.

@rbergmanaf
Copy link

I don't feel strongly, but do like the idea of a short default. @ would probably be the least surprising given the precedent in Type, but as long as it's documented I imagine either way being acceptable personally. Maybe the charm team would have a preference?

@maaslalani
Copy link
Member

I agree on the time out being a great option, I agree on the default of 3 seconds as well having the user manually bump it if they know it will be longer.

Additionally, I would probably have this renamed to Wait (possibly WaitFor / WaitUntil) so it reads a bit like english commands:

Type 'echo "Hello"'
Enter
Wait /hello/ 5s

Copy link
Member

@maaslalani maaslalani left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great! I'm really excited about this feature, just a bit concerned about the naming. I don't know if MatchLine and MatchScreen are the best names.

I also think waiting for the prompt i.e. MatchLine "^>" will be a very popular incantation of the command. I wonder if it's worth having a Wait command that simply Sleeps until the prompt shows up.

token.go Outdated
}
return string(t)
}

func toCamel(s string) string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this extraction ❤️


// CurrentLine returns the current line from the buffer.
func (v *VHS) CurrentLine() (string, error) {
buf, err := v.Page.Eval("() => term.buffer.active.getLine(term.buffer.active.cursorY+term.buffer.active.viewportY).translateToString().trimEnd()")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, this is great! 👌

Copy link
Member

@maaslalani maaslalani left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great! I'm really excited about this feature, just a bit concerned about the naming. I don't know if MatchLine and MatchScreen are the best names.

I also think waiting for the prompt i.e. MatchLine "^>" will be a very popular incantation of the command. I wonder if it's worth having a Wait command that simply Sleeps until the prompt shows up.

@rbergmanaf
Copy link

A simplified command like Wait that just waits for the next prompt to appear without needing an explicit expression for that one case does seem really convenient. It's the main way we are using it at this point, though we have some other cases that benefit from also having the ability to customize the regex.

@mastercactapus
Copy link
Contributor Author

I like how Wait reads as well as implies what it does -- waits for a thing. Follow the what rather than the how.

I think WaitForLine is pretty clear, not sure if WaitForScreen reads as well, though. What about WaitOnScreen?

As for the common case of waiting for prompts, we could potentially do a Wait variation that uses WaitForLine but with a Set-able PROMPT variable or something like that with a sane default. Line & Screen could require a pattern, and Wait would not accept one to prevent the overlap.

@maaslalani
Copy link
Member

maaslalani commented May 24, 2023

I would propose something like the following:

Wait[+Context][@timeout] [regex]

Wait for prompt

Wait # simply wait for the prompt to appear (default timeout of 5 seconds)

Wait with timeout.

Wait@3m # wait for a maximum of 3 minutes for the prompt to appear

Wait with regex, we can decided whether the default is a screen or line.

Wait /hello/ # wait for hello to appear

Wait with context

Wait+Screen /hello/ # wait for hello to appear on the screen.
Wait+Line /hello/ # wait for hello to appear on the line.

Wait with all options:

Wait+Screen@1m /hello/

What are your thoughts? I think this syntax is nicer since it limits the commands but I'm very open to having different API that is potentially nicer?

@mastercactapus
Copy link
Contributor Author

mastercactapus commented May 24, 2023

Context is a nice idea, is that used elsewhere or would this be a new syntax?

As for defaulting, I think we should set the defaults for our "wait for prompt" use case.

Set WaitPattern />$/ # default wait pattern
Set WaitTimeout 5s   # default wait timeout

Wait # can be used in most cases to wait for the next prompt
Wait+Line # identical to the above
Wait@1m # for longer-running commands
Wait /custom-pattern/

Wait+Screen /dialog title/ # match anywhere on the screen

@maaslalani
Copy link
Member

maaslalani commented May 24, 2023

As for defaulting, I think we should set the defaults for our "wait for prompt" use case.

Yup, sounds good!

Context is a nice idea, is that used elsewhere or would this be a new syntax?

This + syntax is used with the Ctrl and Alt commands, i.e.:

Ctrl+L
Alt+Enter

In terms of implementation, the Command would be Wait and the context would be the Options.

@mastercactapus
Copy link
Contributor Author

Okay, that all sounds reasonable, and thanks for the insight on context.

The last bit to clarify is regex parsing -- we can probably piggyback on readString and pass / as the endChar with a new token type REGEX to keep things clear.

Does all sound good?

@maaslalani
Copy link
Member

The new REGEX token sounds great!

@mastercactapus
Copy link
Contributor Author

Excellent, I'll switch this PR to a draft until I'm able to get those changes pushed up for review

@mastercactapus mastercactapus marked this pull request as draft May 25, 2023 01:09
@mastercactapus mastercactapus marked this pull request as ready for review December 17, 2023 17:26
@mastercactapus
Copy link
Contributor Author

@maaslalani ready for another look

@maaslalani
Copy link
Member

@mastercactapus Thanks for your patience on this PR. I will take a look and get this merged in soon!

@maaslalani
Copy link
Member

maaslalani commented Dec 21, 2023

Hey @mastercactapus, I'm running into a timeout panic when a match is not detected:

image

I think we shouldn't panic here but rather do one of two things.

  1. Assume the VHS tape didn't work as intended. Stop immediately.
  2. Assume a timeout is a viable path, continue performing the actions in the tape.

I'm leaning towards option one, current the panic does do that but we should output a nicer message, similar to what it's currently showing without the stack trace:

Timeout waiting for "Wait+Line hi" to match hi

@mastercactapus
Copy link
Contributor Author

For option 2, timeout should not be considered a viable path IMO; it would be dangerous to keep executing commands in an unknown state (e.g., ssh connection failed, it may then execute commands locally). Timeout "should not" happen, so it's an unexpected state from the author's perspective, and I think the expected behavior would be to consider it failed and stop.

To do that/option 1:
Is there a way to stop the VHS outside of a panic? I'd have opted to return an error, but command signatures don't have that -- and it seemed like a big change to refactor everything in a feature PR. It's probably worth the effort, but I think it should be done in a separate PR to keep things reviewable/manageable.

If there's already a built-in way to stop an error, I'm happy to make the update.

@mastercactapus
Copy link
Contributor Author

^ put another way, the panic was intentional to be a "stop with error" for functions that don't return an error

@maaslalani
Copy link
Member

That makes absolute sense! We should error out if a timeout occurs. Yes, we'll likely need a way to bubble up and stop execution of the VHS program in this case. Perhaps we can add a method to mark the VHS instance as failed which would exit or cancel the context that we use when the user press ctrl+c

@3v1n0
Copy link

3v1n0 commented Jan 18, 2024

@denisonbarbosa we should definitely use this feature in our tapes once it will land!

@justsh
Copy link

justsh commented Feb 26, 2024

I've been testing this branch for some CI/CD pipelines and it's worked very well for most of the jobs I've tested it on!
However, I noticed that Wait+Screen occasionally times out even when the job otherwise completed successfully and even after the Wait timeout has been extended significantly.

As a simulation of a command in a CI pipeline that generates a lot of line-based output, consider the following tape which enumerates words in the local words dictionary.

Output dict-words.webm
Output dict-words.txt

Require cat

Sleep 500ms
Type "# Enumerating words in the local dictionary" Enter
Type "nl < /usr/share/dict/words" Sleep 250ms Enter

Wait

Sleep 2s

This tape completes successfully, and the video correctly shows the last page of the dictionary followed by a new prompt line.

However, if I try to match the last line of output to determine success or failure, Wait+Screen will time out.
Here, the last word in my copy of /usr/share/dict/words is ZZZ, so I want to match its presence to determine success:

Output dict-words-ZZZ.webm
Output dict-words-ZZZ.txt

Require cat

Sleep 500ms
Type "# Enumerating words in the local dictionary" Enter
Type "nl < /usr/share/dict/words" Sleep 250ms Enter

Wait+Screen /ZZZ/

Sleep 2s

This fails with the following panic:

panic: timeout waiting for "Screen ZZZ" to match ZZZ; last value was: 478810  Zingiberaceae
478811  zingiberaceous
478812  zingiberene
478813  zingiberol
478814  zingiberone
478815  zingier
478816  zingiest
478817  zinging
478818  zings
478819  zingy
478820  zinjanthropi
478821  Zinjanthropus
478822  zinjanthropus
478823  Zink
478824  zink
478825  zinke
478826  zinked
478827  zinkenite

goroutine 1 [running]:
main.ExecuteWait({{0x10236e9, 0x4}, {0x0, 0x0}, {0xc00069cdc0, 0xa}}, 0xc0001302a0)
        /home/justsh/github.com/mastercactapus/vhs/command.go:210 +0x4b8
main.Command.Execute({{0x10236e9, 0x4}, {0x0, 0x0}, {0xc00069cdc0, 0xa}}, 0xc0001302a0)
        /home/justsh/github.com/mastercactapus/vhs/command.go:114 +0xae
main.Evaluate({0x14fc500, 0xc00069eac0}, {0xc00018a000, 0xd1}, {0x14f64e0, 0xc000078040}, {0xc000259b78, 0x1, 0x0?})
        /home/justsh/github.com/mastercactapus/vhs/evaluator.go:139 +0xd53
main.glob..func2(0x1bf00a0, {0xc000699c40, 0x1, 0x1023799?})
        /home/justsh/github.com/mastercactapus/vhs/main.go:97 +0x44e
github.com/spf13/cobra.(*Command).execute(0x1bf00a0, {0xc000036050, 0x1, 0x1})
        /home/systemexit/.go/1.21.6/pkg/mod/github.com/spf13/cobra@v1.8.0/command.go:983 +0xabc
github.com/spf13/cobra.(*Command).ExecuteC(0x1bf00a0)
        /home/systemexit/.go/1.21.6/pkg/mod/github.com/spf13/cobra@v1.8.0/command.go:1115 +0x3ff
github.com/spf13/cobra.(*Command).Execute(...)
        /home/systemexit/.go/1.21.6/pkg/mod/github.com/spf13/cobra@v1.8.0/command.go:1039
github.com/spf13/cobra.(*Command).ExecuteContext(...)
        /home/systemexit/.go/1.21.6/pkg/mod/github.com/spf13/cobra@v1.8.0/command.go:1032
main.main()
        /home/justsh/github.com/mastercactapus/vhs/main.go:248 +0xc9

I noticed that the last screen of output mentioned in the panic stops short of the last page of the dictionary by about 1000 words, which feels similar to a buffer not being flushed somewhere.

$ nl < /usr/share/dict/words | grep -E 'zinkenite|ZZZ' | tee /dev/tty | cut -f1 | sort -nr | paste -sd- | xargs -t -L1 | bc
478827  zinkenite
479826  ZZZ
echo 479826-478827
999

Noting that the last value text mentioned in the panic seems to be consistent with the screens shown in the Output dict-words.txt from the my first test tape, I wondered if the calculations for vhs.Buffer() might be out of sync.

Specifically, vhs.Buffer() is different from vhs.CurrentLine() in that when vhs.Buffer() calls IBuffer.getLine() it uses Terminal.rows (the size of the viewport) but does not start at term.buffer.active.viewportY like vhs.CurrentLine() does.

Should vhs.Buffer() be calculated from the viewport instead? It makes sense that for small output those calculations would be equivalent, but with a full IBuffer you could get output from "the past" since the IBuffer.viewportY would have "scrolled down".

diff --git a/testing.go b/testing.go
index 8524684..e468e6b 100644
--- a/testing.go
+++ b/testing.go
@@ -55,7 +55,7 @@ func (v *VHS) SaveOutput() {
 // Buffer returns the current buffer.
 func (v *VHS) Buffer() ([]string, error) {
    // Get the current buffer.
-   buf, err := v.Page.Eval("() => Array(term.rows).fill(0).map((e, i) => term.buffer.active.getLine(i).translateToString().trimEnd())")
+   buf, err := v.Page.Eval("() => Array(term.rows).fill(0).map((e, i) => term.buffer.active.getLine(term.buffer.active.viewportY+i).translateToString().trimEnd())")
    if err != nil {
        return nil, fmt.Errorf("read buffer: %w", err)
    }

After making the above change locally and re-running the tape with Wait+Screen /ZZZ/ it completes recording successfully and (as expected) the last screen in Output dict-words-ZZZ.txt also shows the final viewport and prompt for me.

@maaslalani maaslalani changed the title feat: add MatchLine and MatchScreen to wait for expected output feat: add Wait to wait for expected output Mar 14, 2024
@joshmarinacci
Copy link

I've just started using VHS and this feature is the one thing I need before adopting it. I have some commands that take varying amounts of time to run depending on the network. I'd really like to say: Wait until you see text "XYZ" instead of Sleep. Thank you!

This was referenced Mar 27, 2024
@b-per
Copy link

b-per commented Mar 27, 2024

As this PR hasn't been closed yet and it is now conflicting with main, I spent a bit of time today to fork the repo and merge the changes from mastercactapus and justsh .

I also set a GH pipeline to build the relevant binaries.

So, until this PR is part of this repo, If anyone is interested in the Wait feature, you can get the complied binary from the releases in my fork here or can also use eget with eget b-per/vhs to download the relevant binary for your machine.

@ocervell
Copy link

ocervell commented Apr 4, 2024

@b-per I've got an error on your fork:

panic: timeout waiting for "Line" to match >$; last value was: 

goroutine 1 [running]:
main.ExecuteWait({{0x1031214, 0x4}, {0x0, 0x0}, {0x10312b8, 0x4}}, 0xc0002b0000)
        /home/runner/work/vhs/vhs/command.go:159 +0x4b8
main.Execute({{0x1031214, 0x4}, {0x0, 0x0}, {0x10312b8, 0x4}}, 0xc0002b0000)
        /home/runner/work/vhs/vhs/command.go:27 +0xae
main.Evaluate({0x150af18, 0xc000463cc0}, {0xc0001ce000, 0x1e0}, {0x15050c0, 0xc000090028}, {0xc0006d1b78, 0x1, 0x0?})
        /home/runner/work/vhs/vhs/evaluator.go:143 +0xd53
main.glob..func2(0x1bff500, {0xc00047fb20, 0x1, 0x1031244?})
        /home/runner/work/vhs/vhs/main.go:99 +0x44e
github.com/spf13/cobra.(*Command).execute(0x1bff500, {0xc0000ac010, 0x1, 0x1})
        /home/runner/go/pkg/mod/github.com/spf13/cobra@v1.8.0/command.go:983 +0xabc
github.com/spf13/cobra.(*Command).ExecuteC(0x1bff500)
        /home/runner/go/pkg/mod/github.com/spf13/cobra@v1.8.0/command.go:1115 +0x3ff
github.com/spf13/cobra.(*Command).Execute(...)
        /home/runner/go/pkg/mod/github.com/spf13/cobra@v1.8.0/command.go:1039
github.com/spf13/cobra.(*Command).ExecuteContext(...)
        /home/runner/go/pkg/mod/github.com/spf13/cobra@v1.8.0/command.go:1032
main.main()
        /home/runner/work/vhs/vhs/main.go:250 +0xc9

My tape file:

# Where should we write the GIF?
Output demo.gif
Set Shell fish

# Set up a 1200x600 terminal with 46px font.
Set FontSize 25
Set Width 1500
Set Height 1000
Set BorderRadius 10

Type "secator x httpx testphp.vulnweb.com"
Sleep 500ms
Enter
Wait

Edit: nevermind, had to increase the WaitTimeout:

Set WaitPattern />$/
Set WaitTimeout 2m

Is there a way to just wait indefinitely ?

@b-per
Copy link

b-per commented Apr 4, 2024

Yes, the panic behavior was discussed in this comment as well. (this might be why it wasn't merged at that time)

@ocervell
Copy link

ocervell commented Apr 5, 2024

Other than that which happens only when the timeout is reached so kinda makes sense. Other than that, it's worked really well for me once I set a proper (longer) timeout.

@spkane
Copy link

spkane commented May 15, 2024

Are there any updates on the status of this work? What are the next steps that are needed?

@mastercactapus @maaslalani

@mastercactapus
Copy link
Contributor Author

@spkane I updated and resolved the conflicts, the main remaining issue is that there's no good way to have a command return an error (i.e., other than panic), which is needed because WAIT introduces the need for something to fail/timeout possibly.

I'll look at opening a separate PR to allow command funcs to return an error, as I have a bit of time this AM

@spkane
Copy link

spkane commented May 20, 2024

@spkane I updated and resolved the conflicts, the main remaining issue is that there's no good way to have a command return an error (i.e., other than panic), which is needed because WAIT introduces the need for something to fail/timeout possibly.

I'll look at opening a separate PR to allow command funcs to return an error, as I have a bit of time this AM

Thanks, @mastercactapus! I definitely want to test this logic out, as I often create demos where I am waiting for events to occur. I frequently don't know how long the command will take to complete, so currently, I have to do a bunch of testing and guesstimate how long to sleep and then hope that it works out for the given run.

@maaslalani
Copy link
Member

Hey @mastercactapus, I'm running into a timeout panic when a match is not detected:

image I think we shouldn't panic here but rather do one of two things.
  1. Assume the VHS tape didn't work as intended. Stop immediately.
  2. Assume a timeout is a viable path, continue performing the actions in the tape.

I'm leaning towards option one, current the panic does do that but we should output a nicer message, similar to what it's currently showing without the stack trace:

Timeout waiting for "Wait+Line hi" to match hi

We just merged #480 which allows us now to show errors during execution of tapes.

This will make showing the timeout errors much nicer than panics! Thanks for the incredible work @mastercactapus.

@fenio
Copy link

fenio commented Jun 1, 2024

So what's next? Is this ready to be merged?
It really looks like MUST HAVE feature ;)

@maaslalani
Copy link
Member

So what's next? Is this ready to be merged?

It really looks like MUST HAVE feature ;)

Yes, just need to do some testing now and then will merge!

Please feel free to try this branch out as well, feedback from others is always welcome too!

@fenio
Copy link

fenio commented Jun 2, 2024

Sure I'm happy to test it but it will be much easier with having some snapshot binary available to do so.
Currently I'm simply using brew install vhs and I don't really want to break my working setup.

But since I'm recording sessions with kubernetes which means output time is sometimes comletely unpredictable I'm really interested with feature "proceed when prompt is back".

BTW GIFs produced by vhs are crashing Github mobile app but that's their problemand already submitted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Wait for command to finish before continuing