mimic

package module
v0.0.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 27, 2022 License: Apache-2.0 Imports: 16 Imported by: 0

README

mimic - testing terminal interactions

GitHub release (latest SemVer) Go Reference GitHub go.mod Go version

GitHub Go Build Go Report Card

Mimic aims to provide a simple and clean contract for interacting with terminal contents while unit testing.

The idea arose from use of go-survey/survey and interest in using something like charmbracelet/vhs for integration testing of a CLI. A well-crafted applications can use mimic for integration testing of CLIs with full capability to mock dependencies. This differs from integration testing with tools like vhc because mimic runs in-process.

A goal of this project is to simplify testing CLIs using survey by providing a test suite which reduces test boilerplate and adheres to testify's suite interface(s).

Mimic also provides commands for:

  • checking existence of strings in the output buffer
  • checking existence of patterns in the output buffer
  • checking formatted output according to pseudoterminal configuration

Installing

First, get the latest version of mimic:

go get -u github.com/jimschubert/mimic@latest

Then, import mimic:

import "github.com/jimschubert/mimic"

Usage

Refer to documentation for general API and usage.

For a real-world example, see suite_test.go in this repository.

Expect vs Contains

Mimic works by wrapping both Netflix/go-expect and hinshun/vt10x to provide two APIs which require a brief explanation.

Expect

The expect-like APIs (mimic.ExpectString and mimic.ExpectPattern) watch the stream of stdout for a string or pattern, evaluating your criteria on each new byte written to the stream. Once criteria is met, you can't re-evaluate those same bytes. If these are concerns, consider using mimic.ContainsString and mimic.ContainsPattern which both flush pending writes (up to the flush timeout) prior to evaluating conditions.

For example, suppose you want to orchestrate the following terminal output:

? What is your name? Jim
? What is your github username? jimschubert

The following will work in your test:

assert.NoError(t, mimic.ExpectString("What is your name?"))

Expecting on multiple conditions for the same line will fail because it's expecting the contents to be written to stdout again. For example, expecting a partial and full line as follows does not work:

assert.NoError(t, mimic.ExpectString("What is your name?"))
mimic.WriteString("Jim")

// can't do this with ExpectString
assert.NoError(t, mimic.ExpectString("? What is your name?"))

You could use a pattern for both of these:

assert.NoError(t, mimic.ExpectPattern("What is your.*name"))
mimic.WriteString("Jim")
assert.NoError(t, mimic.ExpectPattern("What is your.*name"))
mimic.WriteString("jimschubert")

The above example is contrived to demonstrate a concern with test performance when using ExpectPattern. The pattern is evaluated against every new byte on the stream. You could test this locally by adding a log message to RegexpMatcher.Match in this repository. You'd see something like this:

mimic: [RegexpMatcher] evaluating: ?
mimic: [RegexpMatcher] evaluating: ? 
mimic: [RegexpMatcher] evaluating: ? W
mimic: [RegexpMatcher] evaluating: ? Wh
mimic: [RegexpMatcher] evaluating: ? Wha
mimic: [RegexpMatcher] evaluating: ? What
mimic: [RegexpMatcher] evaluating: ? What 
mimic: [RegexpMatcher] evaluating: ? What i
mimic: [RegexpMatcher] evaluating: ? What is
mimic: [RegexpMatcher] evaluating: ? What is 
mimic: [RegexpMatcher] evaluating: ? What is y
mimic: [RegexpMatcher] evaluating: ? What is yo
mimic: [RegexpMatcher] evaluating: ? What is you
mimic: [RegexpMatcher] evaluating: ? What is your
mimic: [RegexpMatcher] evaluating: ? What is your 
mimic: [RegexpMatcher] evaluating: ? What is your n
mimic: [RegexpMatcher] evaluating: ? What is your na
mimic: [RegexpMatcher] evaluating: ? What is your nam
mimic: [RegexpMatcher] evaluating: ? What is your name
mimic: [RegexpMatcher] evaluating: ?

If you wanted to validate that What is your name? is not output twice, expecting via assert.Error will run until the timeout period. Suppose you have a timeout of 5 seconds (constructed with mimic.WithIdleTimeout(5 * time.Second)). The following assert.Error is technically valid, but adds 5 seconds to your test function:

assert.NoError(t, mimic.ExpectString("What is your name?"))
mimic.WriteString("Jim")
assert.Error(t, mimic.ExpectString("What is your name?"), "This condition should succeed after 5 seconds!")

Once you're done with all interactions, it's a best practice to invoke mimic.NoMoreExpectations(). This flushes remaining bytes to stdout and expects io.EOF on the stream.

Contains

The contains APIs (mimic.ContainsString and mimic.ContainsPattern) both flush pending writes (up to the flush timeout) prior to evaluating conditions.

For example, suppose you want to orchestrate the following terminal output:

? What is your name? Jim
? What is your github username? jimschubert

The following will work in your test:

assert.True(t, mimic.ContainsString("What is your name?"))

The test case described in the Expect second which failed would work using ContainsString:

assert.True(t, mimic.ContainsString("What is your name?"))
mimic.WriteString("Jim")

// can do this with ContainsString, but not ExpectString
assert.True(t, mimic.ContainsString("? What is your name?"))

ContainsString and ContainsPattern work on the full terminal view. Keep this in mind as the following which works serially in the Expect API works a little differently:

assert.True(t, mimic.ContainsPattern("What is your.*name"))
mimic.WriteString("Jim")

// WARNING: Sill passes for "What is your name?" even if "What is your github username?" is never displayed
assert.True(t, mimic.ContainsPattern("What is your.*name"))
mimic.WriteString("jimschubert")

Since ContainsPattern works on the entire view contents, each regex passed to the function is evaluated once. If you were to add a logger within the ContainsPattern function, you'd see a trace similar to (one log for each of the above assertions):

mimic: [ContainsPattern] evaluating: What is your.*name
mimic: [ContainsPattern] evaluating: What is your.*name

You can always mix and match these APIs:

assert.True(t, mimic.ContainsPattern("What is your.*name"))
mimic.WriteString("Jim")
assert.NoError(t, mimic.ExpectPattern("What is your.*name"))
mimic.WriteString("jimschubert")

**Prefer ContainsString or ExpectString over pattern based functions where possible.

License

This project is licensed under Apache 2.0.

Documentation

Overview

Package mimic provides a utility for interacting with console or terminal based applications.

A mimic/Mimic internally constructs two pseudo-terminals: one wrapping go-expect, and another construct of creak/pty. This allows for either stream-based or view-based inspection of strings/patterns.

The key difference between the two is that stream-based inspections provided by Mimic.ExpectString and Mimic.ExpectPattern will wait for a configurable amount of time for any text matching the criteria, then _fail_ if no match is found. The search criteria passed to these functions is evaluated repeatedly as bytes are written to your output stream. Keep this in mind, as very complex patterns can be slow. The underlying views are raw pty, and the output is therefore not formatted as it would be within a terminal.

The view-based inspections provided by Mimic.ContainsString and Mimic.ContainsPattern, on the other hand, will wait for the bound output stream to complete processing before applying the search criteria to the entire formatted view. This takes configurable terminal columns/rows into account. These default to a large standard of 132 columns and 24 rows. Internally, this is implemented via github.com/hinshun/vt10x.

Usage

A mimic value implements io.ReadWriteCloser and also satisfies the following interfaces:

 type fileWriter interface {
	io.Writer
	Fd() uintptr
 }

 type fileReader interface {
	io.Reader
	Fd() uintptr
 }

This allows Mimic values to be used in place of Stdin/Stdout/Stderr in most scenarios, including implementations using github.com/AlecAivazis/survey/v2. For example:

 package main

 import (
	"fmt"
	"os"

	"github.com/AlecAivazis/survey/v2"
	"github.com/jimschubert/mimic"
 )

 func main() {
	console, _ := mimic.NewMimic()
	answers := struct {
		Name string
		Age  int
	}{}

	go func() {
		// errors ignored for brevity
		console.ExpectString("What is your name?")
		console.WriteString("Tom\n")
		console.ExpectString("How old are you?")
		console.WriteString("20\n")
		console.ExpectString("Tom", "20")
		if !console.ContainsString("What is your name?", "How old are you?", "Tom", "20") {
			panic("My answers weren't displayed!")
		}
		_ = console.NoMoreExpectations()
	}()

	_ = survey.Ask([]*survey.Question{
		{Name: "name", Prompt: &survey.Input{Message: "What is your name?"}},
		{Name: "age", Prompt: &survey.Input{Message: "How old are you?"}},
	}, &answers,
		survey.WithStdio(console.Tty(), console.Tty(), console.Tty()),
	)
	fmt.Fprintf(os.Stdout, "%s is %d.\n", answers.Name, answers.Age)
 }

Notice in the above example that all expectations should be invoked asynchronously from the thread being instrumented.

Testing

Mimic provides a Suite based on github.com/stretchr/testify/suite which allows creation of a new mimic per test, or a suite-level mimic can be created for more advanced scenarios. Embed suite.Suite into a test struct, then add Test* functions to implement your tests. Follow testify's documentation for more. Here's an slimmed-down example from mimic's own tests:

  package suite

  import (
	"context"
	"io"
	"strings"
	"testing"
	"time"

	"github.com/jimschubert/mimic"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/suite"
  )

  type MyTests struct {
	Suite
	suiteRuntimeDuration time.Duration
  }

  func (m *MyTests) SetupSuite() {
	assert.Greaterf(m.T(), m.suiteRuntimeDuration, 0*time.Second, "Suite runtime must be marked as more than 0 units of time")
  }

  func (m *MyTests) TestMimicWriteRead() {
	m.T().Log("Invoked TestMimicWithOptions")
	terminalWidth := 80
	wrapLength := 20
	console, err := m.Mimic(
		mimic.WithIdleDuration(25*time.Millisecond),
		mimic.WithIdleTimeout(1*time.Second),
		mimic.WithSize(24, terminalWidth),
	)

	assert.NoError(m.T(), err, "Standard invocation with options should not produce an error")
	assert.NotNil(m.T(), console, "Mimic instance must should not be nil on errorless construction")

	character := "X"
	fullWriteWidth := terminalWidth + wrapLength
	full := strings.Repeat(character, fullWriteWidth)
	written, err := console.WriteString(full)

	assert.NoError(m.T(), err, "pty should have allowed the write!")
	assert.Equal(m.T(), written, fullWriteWidth, "pty should have written all bytes!")

	assert.NoError(m.T(), console.ExpectString(full), "Emulated terminal should be %d columns, not %d columns as the written string", terminalWidth, fullWriteWidth)
	assert.Error(m.T(), console.ExpectString(strings.Repeat(character, terminalWidth+1)), "Emulated terminal should be %d columns, but found %d characters", terminalWidth, terminalWidth+1)
	assert.Error(m.T(), console.ExpectString(strings.Repeat(character, terminalWidth)), "Emulated terminal should have wrapped text at %d columns", terminalWidth)

	assert.False(m.T(), console.ContainsString(full), "underlying terminal is expected to wrap")
	assert.True(m.T(), console.ContainsString(strings.Repeat(character, terminalWidth)+"\n"+strings.Repeat(character, wrapLength)), "underlying terminal is expected to wrap")
  }

  func TestMimicOperationsSuite(t *testing.T) {
	test := new(MyTests)
	test.suiteRuntimeDuration = 30 * time.Second
	test.Init(WithMaxRuntime(test.suiteRuntimeDuration))

	suite.Run(t, test)
  }

Index

Examples

Constants

View Source
const (
	// DefaultColumns for the underlying view-based terminal's column count (i.e. width)
	DefaultColumns = 132
	// DefaultRows for the underlying view-based terminal's row count (i.e. height)
	DefaultRows = 24
	// DefaultIdleTimeout when the underlying terminal is idle (i.e. fails to match an expectation), used by functions
	// such as Mimic.ExpectString, Mimic.ContainsString, Mimic.ExpectPattern, and Mimic.ContainsPattern
	DefaultIdleTimeout = 250 * time.Millisecond
	// DefaultFlushTimeout for mimic's flush operation. Mimic will invoke flush only if there are outstanding operations
	// from Mimic.Write or Mimic.WriteString.
	DefaultFlushTimeout = 25 * time.Millisecond
	// DefaultIdleDuration for mimic to consider the terminal idle via Mimic.WaitForIdle.
	DefaultIdleDuration = 100 * time.Millisecond
)

Variables

This section is empty.

Functions

This section is empty.

Types

type Experimental added in v0.0.3

type Experimental interface {
	// Console provides access to the underlying expect.Console
	Console() (expect.Console, error)
	// Terminal provides access to the underlying vt10x.Terminal
	Terminal() (vt10x.Terminal, error)
}

An Experimental contract which can be changed or removed at any time. This is intended for use by users for experimentation purposes only.

type Mimic

type Mimic struct {
	Experimental Experimental
	// contains filtered or unexported fields
}

Mimic is a utility for mimicking operations on a pseudo terminal

func NewMimic

func NewMimic(opts ...Option) (*Mimic, error)

NewMimic creates a Mimic, which emulates a pseudo terminal device and provides utility functions for inputs/assertions/expectations upon it

func (*Mimic) Close

func (m *Mimic) Close() (err error)

Close causes any underlying emulation to close. Fulfills the io.Closer interface.

func (*Mimic) ContainsPattern

func (m *Mimic) ContainsPattern(pattern ...string) bool

ContainsPattern determines if the emulated terminal's view contains one or more specified patterns. Patterns are evaluated against formatted terminal contents, stripped of ANSI escape characters and trimmed.

func (*Mimic) ContainsString

func (m *Mimic) ContainsString(str ...string) bool

ContainsString determines if the emulated terminal's view matches specified string. A "view" takes into account terminal row/columns. Terminal contents are stripped of ANSI escape characters and trimmed.

Example
package main

import (
	"fmt"
	"time"

	"github.com/jimschubert/mimic"
)

func main() {
	columns := 26
	m, _ := mimic.NewMimic(
		mimic.WithSize(24, columns),
		mimic.WithFlushTimeout(75*time.Millisecond),
		mimic.WithIdleDuration(50*time.Millisecond),
	)

	// create three rows of text…
	for row := 1; row <= 3; row++ {
		for i := 'a'; i <= 'z'; i++ {
			_, _ = m.WriteString(string(i))
		}
	}

	if m.ContainsString("abcdefghijklmnopqrstuvwxyz") {
		fmt.Println("Found the alphabet!")
	}

	if m.ContainsString("za") {
		fmt.Println("[Error] Terminal did not wrap!")
	}

	formatted := mimic.Viewer{Mimic: m, StripAnsi: true, Trim: true}
	fmt.Printf("\nFormatted View (%d columns):\n%s\n", columns, formatted.String())

}
Output:

Found the alphabet!

Formatted View (26 columns):
abcdefghijklmnopqrstuvwxyz
abcdefghijklmnopqrstuvwxyz
abcdefghijklmnopqrstuvwxyz

func (*Mimic) ExpectPattern added in v0.0.2

func (m *Mimic) ExpectPattern(pattern ...string) error

ExpectPattern waits for the emulated terminal's view to contain one or more specified patterns

func (*Mimic) ExpectString added in v0.0.2

func (m *Mimic) ExpectString(str ...string) error

ExpectString waits for the emulated terminal's view to contain one or more specified strings

Example
package main

import (
	"fmt"
	"strings"

	"github.com/jimschubert/mimic"
)

func main() {
	columns := 30
	m, _ := mimic.NewMimic(
		mimic.WithSize(24, columns),
	)

	// text is Hi*16 (or, 32 letters); column width is 30
	text := strings.Repeat("Hi", 16)
	_, _ = m.WriteString(text)

	// Expect the first line (note, no newline expectations)
	if err := m.ExpectString(strings.Repeat("Hi", 15)); err == nil {
		fmt.Printf("Found: %s\n\n", strings.Repeat("Hi", 15))
	}

	// Expect the second line (note, no newline expectations)
	if err := m.ExpectString("Hi"); err != nil {
		fmt.Println("The text should have wrapped!")
	}

	_ = m.NoMoreExpectations()

	formatted := mimic.Viewer{Mimic: m, StripAnsi: true, Trim: true}
	fmt.Printf("Formatted View (%d columns):\n%s\n", columns, formatted.String())

}
Output:

Found: HiHiHiHiHiHiHiHiHiHiHiHiHiHiHi

Formatted View (30 columns):
HiHiHiHiHiHiHiHiHiHiHiHiHiHiHi
Hi
Example (With_ContainsString)
package main

import (
	"fmt"
	"time"

	"github.com/jimschubert/mimic"
)

func main() {
	columns := 26
	m, _ := mimic.NewMimic(
		mimic.WithSize(24, columns),
		mimic.WithIdleTimeout(50*time.Millisecond),
	)

	go func() {
		// create three rows of text…
		for row := 1; row <= 3; row++ {
			for i := 'a'; i <= 'z'; i++ {
				// note we don't write \n here. Formatting defined by column width.
				_, _ = m.WriteString(string(i))
			}
		}
		_, _ = m.WriteString("\nDONE.")
	}()

	_ = m.ExpectString("DONE.")

	// force Flush and expect EOF
	// this can be omitted if you don't want to expect EOF
	m.NoMoreExpectations()

	if m.ContainsString("DONE.") {
		fmt.Printf("Found 'DONE.'\n\n")
	}

	if m.ContainsString("za") {
		fmt.Println("Terminal did not wrap!")
	}

	formatted := mimic.Viewer{Mimic: m, StripAnsi: true, Trim: true}
	fmt.Printf("Formatted View (%d columns):\n%s\n", columns, formatted.String())

}
Output:

Found 'DONE.'

Formatted View (26 columns):
abcdefghijklmnopqrstuvwxyz
abcdefghijklmnopqrstuvwxyz
abcdefghijklmnopqrstuvwxyz
DONE.

func (*Mimic) Fd added in v0.0.2

func (m *Mimic) Fd() uintptr

Fd file descriptor of underlying pty.

func (*Mimic) Flush added in v0.0.3

func (m *Mimic) Flush() error

Flush (or attempt to flush) any pending writes done via Write or WriteString.

func (*Mimic) NoMoreExpectations added in v0.0.3

func (m *Mimic) NoMoreExpectations() error

NoMoreExpectations signals the underlying buffer to finish writing bytes to the underlying pseudo-terminal.

func (*Mimic) Read

func (m *Mimic) Read(p []byte) (n int, err error)

Read bytes from the underlying terminal Fulfills the io.Reader interface.

func (*Mimic) Tty

func (m *Mimic) Tty() *os.File

Tty provides the underlying tty required for interacting with this console

func (*Mimic) WaitForIdle

func (m *Mimic) WaitForIdle(ctx context.Context) error

WaitForIdle causes the emulated terminal to spin, waiting the terminal output to "stabilize" (i.e. no writes are occurring)

func (*Mimic) Write

func (m *Mimic) Write(b []byte) (int, error)

Write writes a value to the underlying terminal. Fulfills the io.Writer interface.

func (*Mimic) WriteString

func (m *Mimic) WriteString(str string) (int, error)

WriteString writes a value to the underlying terminal

type Option

type Option func(*mimicOpt)

Option extends functionality of Mimic via functional options. see WithOutput, WithStdout, WithSize

func WithFlushTimeout added in v0.0.4

func WithFlushTimeout(timeout time.Duration) Option

WithFlushTimeout defines the timeout for mimic's flush operation. Mimic will invoke flush only if there are outstanding operations from Mimic.Write or Mimic.WriteString.

func WithIdleDuration

func WithIdleDuration(duration time.Duration) Option

WithIdleDuration defines the duration required for mimic to consider the terminal idle via Mimic.WaitForIdle.

func WithIdleTimeout

func WithIdleTimeout(timeout time.Duration) Option

WithIdleTimeout defines the timeout period for mimic operations which wait for the terminal to become idle

func WithInput

func WithInput(r io.Reader) Option

WithInput accepts input from r

func WithOutput

func WithOutput(w io.Writer) Option

WithOutput writes a copy of emulated console output to w Not compatible with WithStdout

func WithPipeFromOS

func WithPipeFromOS() Option

WithPipeFromOS determines whether standard os streams should be included in the pseudo terminal

func WithSize

func WithSize(rows, columns int) Option

WithSize defines the size of the emulated terminal

type PatternError

type PatternError struct {
	Contents       string
	FailedPatterns []string
}

func (PatternError) Error

func (p PatternError) Error() string

type Viewer

type Viewer struct {
	Mimic     *Mimic
	StripAnsi bool
	Trim      bool
}

Viewer is a utility for providing a String function on a mimic value. This is intentionally separated from mimic.Mimic to allow for multiple outputs for a single mimic, and to remove any confusion about what String might refer to.

func (*Viewer) String

func (v *Viewer) String() string

String provides the full underlying dump of the terminal's view.

Example
package main

import (
	"fmt"
	"time"

	"github.com/jimschubert/mimic"
)

func main() {
	m, _ := mimic.NewMimic(
		mimic.WithSize(24, 80),
		mimic.WithIdleTimeout(300*time.Millisecond),
	)

	text := "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Sed vulputate odio ut enim blandit volutpat maecenas volutpat."
	_, _ = m.WriteString(text)

	_ = m.Flush()

	formatted := mimic.Viewer{Mimic: m, StripAnsi: true, Trim: true}
	fmt.Printf("%s\n", formatted.String())

}
Output:

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i
ncididunt ut labore et dolore magna aliqua. Sed vulputate odio ut enim blandit v
olutpat maecenas volutpat.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL