charmbracelet / x

Charm experimental packages
MIT License
151 stars 18 forks source link

teatest: Obtaining final state of terminal #212

Open crab-apple opened 1 month ago

crab-apple commented 1 month ago

I'm not sure if I'm looking at this the right way. Here's what I'm trying to achieve:

Say I have a program that starts by making some asynchronous request to an external system (with a tea.Cmd).

In my tests I use a fake instead of the real system. My fake returns a result with a delay of a few milliseconds.

Now I want to test that, when the command is complete, my program displays "foo" and it doesn't display "bar". Testing that it displays "foo" is easy with WaitFor.

teatest.WaitFor(
    t, tm.Output(),
    func(bts []byte) bool {
        return bytes.Contains(bts, []byte("foo"))
    }
    ...
)

But how do I test that the program doesn't display "bar"?

One approach would be to wait until the program displays "foo", and then get the FinalOutput and assert that it doesn't contain "bar". This approach has two issues:

  1. With the current implementation, as soon as I've used WaitFor I've consumed part of the output, so FinalOutput will not actually give me the final "display".
  2. Even if I could use WaitFor without consuming the output, the condition "contains 'foo'" still doesn't give me any guarantee that I'm looking at the final state. The program could be in the middle of writing out the output.

Before switching to teatest, I was using a homemade solution for testing where I didn't have problem 1 above but I still had problem 2. Essentially I haven't found a way to wait until the program has reached a "stable" state, for lack of a better term. I suppose that would be a state where there are no commands still pending to return a result, and the program has completely processed all the pending messages.

Am I missing something?

caarlos0 commented 1 month ago

you can check tm.FinalOutput() after the wait, i believe.

crab-apple commented 1 month ago

you can check tm.FinalOutput() after the wait, i believe.

But it doesn't return the full output, as it has already been partially or totally consumed by the wait, right? Unless I'm doing something wrong. Here is an example:

Given this model, which always returns "Hello" as the view:

type Model struct {
}

func InitialModel() Model {
    return Model{}
}

func (m Model) Init() tea.Cmd {
    return nil
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    return m, nil
}

func (m Model) View() string {
    return "Hello"
}

I can assert the output with WaitFor:

func TestCheckIntermediate(t *testing.T) {

    tm := teatest.NewTestModel(t, InitialModel())

    teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
        return bytes.Contains(bts, []byte("Hello"))
    })
}

And I can assert the final output:

func TestCheckFinal(t *testing.T) {

    tm := teatest.NewTestModel(t, InitialModel())

    assert.NoError(t, tm.Quit())

    final, err := io.ReadAll(tm.FinalOutput(t))
    assert.NoError(t, err)

    assert.True(t, bytes.Contains(final, []byte("Hello")), "Final output should contain 'Hello'")
}

But if I wait and then also check the final output, then the test fails:

func TestCheckIntermediateAndFinal(t *testing.T) {

    tm := teatest.NewTestModel(t, InitialModel())

    teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
        return bytes.Contains(bts, []byte("Hello"))
    })

    assert.NoError(t, tm.Quit())

    final, err := io.ReadAll(tm.FinalOutput(t))
    assert.NoError(t, err)

    assert.True(t, bytes.Contains(final, []byte("Hello")), "Final output should contain 'Hello'.")
}
caarlos0 commented 1 month ago

You want the final render of the model, right?

If so, you can do something like:


func TestCheckIntermediateAndFinal(t *testing.T) {
    tm := teatest.NewTestModel(t, Model{})

    teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
        return bytes.Contains(bts, []byte("Hello"))
    })

    if err := tm.Quit(); err != nil {
        t.Fatal(err)
    }

    final := tm.FinalModel(t).View()
    if final != "Hello" {
        t.Errorf("expected model to be 'Hello', was '%s'", final)
    }
}

the View() method should not have any side effect (i.e. change the model), so you can do it like this :)

crab-apple commented 1 month ago

Right, that makes sense.

I feel it makes more sense even, as my tests are interested in the view itself, rather than the output stream. The output stream could contain content of previous views that have been since painted over (in my limited understanding of how this works).

Which kind of begs the question: when waiting for a given state, wouldn't I be interested in the view as well, and not in the raw output? Would it make sense to have a method like this?

    teatest.WaitForView(t, tm, func(view string) bool {
        return strings.Contains(view, "Hello")
    })