src.elv.sh@v0.21.0-dev.0.20240515223629-06979efb9a2a/pkg/eval/evaltest/test_transcript.go (about)

     1  // Package evaltest supports testing the Elvish interpreter and libraries.
     2  //
     3  // The entrypoint of this package is [TestTranscriptsInFS]. Typical usage looks
     4  // like this:
     5  //
     6  //	import (
     7  //		"embed"
     8  //		"src.elv.sh/pkg/eval/evaltest"
     9  //	)
    10  //
    11  //	//go:embed *.elv *.elvts
    12  //	var transcripts embed.FS
    13  //
    14  //	func TestTranscripts(t *testing.T) {
    15  //		evaltest.TestTranscriptsInFS(t, transcripts)
    16  //	}
    17  //
    18  // See [src.elv.sh/pkg/transcript] for how transcript sessions are discovered.
    19  //
    20  // # Setup functions
    21  //
    22  // [TestTranscriptsInFS] accepts variadic arguments in (name, f) pairs, where
    23  // name must not contain any spaces. Each pair defines a setup function that may
    24  // be referred to in the transcripts with the directive "//name".
    25  //
    26  // The setup function f may take a *testing.T, *eval.Evaler and a string
    27  // argument. All of them are optional but must appear in that order. If it takes
    28  // a string argument, the directive can be followed by an argument after a space
    29  // ("//name argument"), and that argument is passed to f. The argument itself
    30  // may contain spaces.
    31  //
    32  // The following setup functions are predefined:
    33  //
    34  //   - skip-test: Don't run this test. Useful for examples in .d.elv files that
    35  //     shouldn't be run as tests.
    36  //
    37  //   - in-temp-dir: Run inside a temporary directory.
    38  //
    39  //   - set-env $name $value: Run with the environment variable $name set to
    40  //     $value.
    41  //
    42  //   - unset-env $name: Run with the environment variable $name unset.
    43  //
    44  //   - eval $code: Evaluate the argument as Elvish code.
    45  //
    46  //   - only-on $cond: Evaluate $cond like a //go:build constraint and only
    47  //     run the test if the constraint is satisfied.
    48  //
    49  //     The syntax is the same as //go:build constraints, but the set of
    50  //     supported tags is different and consists of: GOARCH and GOOS values,
    51  //     "unix", "32bit" and "64bit".
    52  //
    53  //   - deprecation-level $x: Run with deprecation level set to $x.
    54  //
    55  // These setup functions can then be used in transcripts as directives. By
    56  // default, they only apply to the current session; adding a "each:" prefix
    57  // makes them apply to descendant sessions too.
    58  //
    59  //	//global-setup
    60  //	//each:global-setup-2
    61  //
    62  //	# h1 #
    63  //	//h1-setup
    64  //	//each:h1-setup2
    65  //
    66  //	## h2 ##
    67  //	//h2-setup
    68  //
    69  //	// All of globa-setup2, h1-setup2 and h2-setup are run for this session, in
    70  //	// that
    71  //
    72  //	~> echo foo
    73  //	foo
    74  //
    75  // # ELVISH_TRANSCRIPT_RUN
    76  //
    77  // The environment variable ELVISH_TRANSCRIPT_RUN may be set to a string
    78  // $filename:$lineno. If the location falls within the code lines of an
    79  // interaction, the following happens:
    80  //
    81  //  1. Only the session that the interaction belongs to is run, and only up to
    82  //     the located interaction.
    83  //
    84  //  2. If the actual output doesn't match what's in the file, the test fails,
    85  //     and writes out a machine readable instruction to update the file to match
    86  //     the actual output.
    87  //
    88  // As an example, consider the following fragment of foo_test.elvts (with line
    89  // numbers):
    90  //
    91  //	12 ~> echo foo
    92  //	13    echo bar
    93  //	14 lorem
    94  //	15 ipsum
    95  //
    96  // Running
    97  //
    98  //	env ELVISH_TRANSCRIPT_RUN=foo_test.elvts:12 go test -run TestTranscripts
    99  //
   100  // will end up with a test failure, with a message like the following (the line
   101  // range is left-closed, right-open):
   102  //
   103  //	UPDATE {"fromLine": 14, "toLine": 16, "content": "foo\nbar\n"}
   104  //
   105  // This mechanism enables editor plugins that can fill or update the output of
   106  // transcript tests without requiring user to leave the editor.
   107  package evaltest
   108  
   109  import (
   110  	"bytes"
   111  	"encoding/json"
   112  	"fmt"
   113  	"go/build/constraint"
   114  	"io/fs"
   115  	"math"
   116  	"os"
   117  	"regexp"
   118  	"runtime"
   119  	"strconv"
   120  	"strings"
   121  	"testing"
   122  
   123  	"src.elv.sh/pkg/diag"
   124  	"src.elv.sh/pkg/diff"
   125  	"src.elv.sh/pkg/eval"
   126  	"src.elv.sh/pkg/eval/vals"
   127  	"src.elv.sh/pkg/mods"
   128  	"src.elv.sh/pkg/must"
   129  	"src.elv.sh/pkg/parse"
   130  	"src.elv.sh/pkg/prog"
   131  	"src.elv.sh/pkg/testutil"
   132  	"src.elv.sh/pkg/transcript"
   133  )
   134  
   135  // TestTranscriptsInFS extracts all Elvish transcript sessions from .elv and
   136  // .elvts files in fsys, and runs each of them as a test.
   137  func TestTranscriptsInFS(t *testing.T, fsys fs.FS, setupPairs ...any) {
   138  	nodes, err := transcript.ParseFromFS(fsys)
   139  	if err != nil {
   140  		t.Fatalf("parse transcript sessions: %v", err)
   141  	}
   142  	var run *runCfg
   143  	if runEnv := os.Getenv("ELVISH_TRANSCRIPT_RUN"); runEnv != "" {
   144  		filename, lineNo, ok := parseFileNameAndLineNo(runEnv)
   145  		if !ok {
   146  			t.Fatalf("can't parse ELVISH_TRANSCRIPT_RUN: %q", runEnv)
   147  		}
   148  		var node *transcript.Node
   149  		for _, n := range nodes {
   150  			if n.Name == filename {
   151  				node = n
   152  				break
   153  			}
   154  		}
   155  		if node == nil {
   156  			t.Fatalf("can't find file %q", filename)
   157  		}
   158  		nodes = []*transcript.Node{node}
   159  		outputPrefix := ""
   160  		if strings.HasSuffix(filename, ".elv") {
   161  			outputPrefix = "# "
   162  		}
   163  		run = &runCfg{lineNo, outputPrefix}
   164  	}
   165  	testTranscripts(t, buildSetupDirectives(setupPairs), nodes, nil, run)
   166  }
   167  
   168  type runCfg struct {
   169  	line         int
   170  	outputPrefix string
   171  }
   172  
   173  func parseFileNameAndLineNo(s string) (string, int, bool) {
   174  	i := strings.LastIndexByte(s, ':')
   175  	if i == -1 {
   176  		return "", 0, false
   177  	}
   178  	filename, lineNoString := s[:i], s[i+1:]
   179  	lineNo, err := strconv.Atoi(lineNoString)
   180  	if err != nil {
   181  		return "", 0, false
   182  	}
   183  	return filename, lineNo, true
   184  }
   185  
   186  var solPattern = regexp.MustCompile("(?m:^)")
   187  
   188  func testTranscripts(t *testing.T, sd *setupDirectives, nodes []*transcript.Node, setups []setupFunc, run *runCfg) {
   189  	for _, node := range nodes {
   190  		if run != nil && !(node.LineFrom <= run.line && run.line < node.LineTo) {
   191  			continue
   192  		}
   193  		t.Run(node.Name, func(t *testing.T) {
   194  			ev := eval.NewEvaler()
   195  			mods.AddTo(ev)
   196  			for _, setup := range setups {
   197  				setup(t, ev)
   198  			}
   199  			var eachSetups []setupFunc
   200  			for _, directive := range node.Directives {
   201  				setup, each, err := sd.compile(directive)
   202  				if err != nil {
   203  					t.Fatal(err)
   204  				}
   205  				setup(t, ev)
   206  				if each {
   207  					eachSetups = append(eachSetups, setup)
   208  				}
   209  			}
   210  			for _, interaction := range node.Interactions {
   211  				if run != nil && interaction.CodeLineFrom > run.line {
   212  					break
   213  				}
   214  				want := interaction.Output
   215  				got := evalAndCollectOutput(ev, interaction.Code)
   216  				if want != got {
   217  					if run == nil {
   218  						t.Errorf("\n%s\n-want +got:\n%s",
   219  							interaction.PromptAndCode(), diff.DiffNoHeader(want, got))
   220  					} else if interaction.CodeLineFrom <= run.line && run.line < interaction.CodeLineTo {
   221  						content := got
   222  						if run.outputPrefix != "" {
   223  							// Insert output prefix at each SOL, except for the
   224  							// SOL after the trailing newline.
   225  							content = solPattern.ReplaceAllLiteralString(strings.TrimSuffix(content, "\n"), run.outputPrefix) + "\n"
   226  						}
   227  						correction := struct {
   228  							FromLine int    `json:"fromLine"`
   229  							ToLine   int    `json:"toLine"`
   230  							Content  string `json:"content"`
   231  						}{interaction.OutputLineFrom, interaction.OutputLineTo, content}
   232  						t.Errorf("UPDATE %s", must.OK1(json.Marshal(correction)))
   233  					}
   234  				}
   235  			}
   236  			if len(node.Children) > 0 {
   237  				// TODO: Use slices.Concat when Elvish requires Go 1.22
   238  				allSetups := make([]setupFunc, 0, len(setups)+len(eachSetups))
   239  				allSetups = append(allSetups, setups...)
   240  				allSetups = append(allSetups, eachSetups...)
   241  				testTranscripts(t, sd, node.Children, allSetups, run)
   242  			}
   243  		})
   244  	}
   245  }
   246  
   247  type (
   248  	setupFunc    func(*testing.T, *eval.Evaler)
   249  	argSetupFunc func(*testing.T, *eval.Evaler, string)
   250  )
   251  
   252  type setupDirectives struct {
   253  	setupMap    map[string]setupFunc
   254  	argSetupMap map[string]argSetupFunc
   255  }
   256  
   257  func buildSetupDirectives(setupPairs []any) *setupDirectives {
   258  	if len(setupPairs)%2 != 0 {
   259  		panic(fmt.Sprintf("variadic arguments must come in pairs, got %d", len(setupPairs)))
   260  	}
   261  	setupMap := map[string]setupFunc{
   262  		"in-temp-dir": func(t *testing.T, ev *eval.Evaler) { testutil.InTempDir(t) },
   263  		"skip-test":   func(t *testing.T, _ *eval.Evaler) { t.SkipNow() },
   264  	}
   265  	argSetupMap := map[string]argSetupFunc{
   266  		"set-env": func(t *testing.T, ev *eval.Evaler, arg string) {
   267  			name, value, _ := strings.Cut(arg, " ")
   268  			testutil.Setenv(t, name, value)
   269  		},
   270  		"unset-env": func(t *testing.T, ev *eval.Evaler, name string) {
   271  			testutil.Unsetenv(t, name)
   272  		},
   273  		"eval": func(t *testing.T, ev *eval.Evaler, code string) {
   274  			err := ev.Eval(
   275  				parse.Source{Name: "[setup]", Code: code},
   276  				eval.EvalCfg{Ports: eval.DummyPorts})
   277  			if err != nil {
   278  				t.Fatalf("setup failed: %v\n", err)
   279  			}
   280  		},
   281  		"only-on": func(t *testing.T, _ *eval.Evaler, arg string) {
   282  			expr, err := constraint.Parse("//go:build " + arg)
   283  			if err != nil {
   284  				t.Fatalf("parse constraint %q: %v", arg, err)
   285  			}
   286  			if !expr.Eval(func(tag string) bool {
   287  				switch tag {
   288  				case "unix":
   289  					return isUNIX
   290  				case "32bit":
   291  					return math.MaxInt == math.MaxInt32
   292  				case "64bit":
   293  					return math.MaxInt == math.MaxInt64
   294  				default:
   295  					return tag == runtime.GOOS || tag == runtime.GOARCH
   296  				}
   297  			}) {
   298  				t.Skipf("constraint not satisfied: %s", arg)
   299  			}
   300  		},
   301  		"deprecation-level": func(t *testing.T, _ *eval.Evaler, arg string) {
   302  			testutil.Set(t, &prog.DeprecationLevel, must.OK1(strconv.Atoi(arg)))
   303  		},
   304  	}
   305  	for i := 0; i < len(setupPairs); i += 2 {
   306  		name := setupPairs[i].(string)
   307  		if setupMap[name] != nil || argSetupMap[name] != nil {
   308  			panic(fmt.Sprintf("there's already a setup functions named %s", name))
   309  		}
   310  		switch f := setupPairs[i+1].(type) {
   311  		case func():
   312  			setupMap[name] = func(_ *testing.T, _ *eval.Evaler) { f() }
   313  		case func(*testing.T):
   314  			setupMap[name] = func(t *testing.T, ev *eval.Evaler) { f(t) }
   315  		case func(*eval.Evaler):
   316  			setupMap[name] = func(t *testing.T, ev *eval.Evaler) { f(ev) }
   317  		case func(*testing.T, *eval.Evaler):
   318  			setupMap[name] = f
   319  		case func(string):
   320  			argSetupMap[name] = func(_ *testing.T, _ *eval.Evaler, s string) { f(s) }
   321  		case func(*testing.T, string):
   322  			argSetupMap[name] = func(t *testing.T, _ *eval.Evaler, s string) { f(t, s) }
   323  		case func(*eval.Evaler, string):
   324  			argSetupMap[name] = func(_ *testing.T, ev *eval.Evaler, s string) { f(ev, s) }
   325  		case func(*testing.T, *eval.Evaler, string):
   326  			argSetupMap[name] = f
   327  		default:
   328  			panic(fmt.Sprintf("unsupported setup function type: %T", f))
   329  		}
   330  	}
   331  	return &setupDirectives{setupMap, argSetupMap}
   332  }
   333  
   334  func (sd *setupDirectives) compile(directive string) (f setupFunc, each bool, err error) {
   335  	cutDirective := directive
   336  	if s, ok := strings.CutPrefix(directive, "each:"); ok {
   337  		cutDirective = s
   338  		each = true
   339  	}
   340  	name, arg, _ := strings.Cut(cutDirective, " ")
   341  	if f, ok := sd.setupMap[name]; ok {
   342  		if arg != "" {
   343  			return nil, false, fmt.Errorf("setup function %q doesn't support arguments", name)
   344  		}
   345  		return f, each, nil
   346  	} else if f, ok := sd.argSetupMap[name]; ok {
   347  		return func(t *testing.T, ev *eval.Evaler) {
   348  			f(t, ev, arg)
   349  		}, each, nil
   350  	} else {
   351  		return nil, false, fmt.Errorf("unknown setup function %q in directive %q", name, directive)
   352  	}
   353  }
   354  
   355  var valuePrefix = "▶ "
   356  
   357  func evalAndCollectOutput(ev *eval.Evaler, code string) string {
   358  	port1, collect1 := must.OK2(eval.CapturePort())
   359  	port2, collect2 := must.OK2(eval.CapturePort())
   360  	ports := []*eval.Port{eval.DummyInputPort, port1, port2}
   361  
   362  	ctx, done := eval.ListenInterrupts()
   363  	err := ev.Eval(
   364  		parse.Source{Name: "[tty]", Code: code},
   365  		eval.EvalCfg{Ports: ports, Interrupts: ctx})
   366  	done()
   367  
   368  	values, stdout := collect1()
   369  	_, stderr := collect2()
   370  
   371  	var sb strings.Builder
   372  	for _, value := range values {
   373  		sb.WriteString(valuePrefix + vals.ReprPlain(value) + "\n")
   374  	}
   375  	sb.Write(normalizeLineEnding(stripSGR(stdout)))
   376  	sb.Write(normalizeLineEnding(stripSGR(stderr)))
   377  
   378  	if err != nil {
   379  		if shower, ok := err.(diag.Shower); ok {
   380  			sb.WriteString(stripSGRString(shower.Show("")))
   381  		} else {
   382  			sb.WriteString(err.Error())
   383  		}
   384  		sb.WriteByte('\n')
   385  	}
   386  
   387  	return sb.String()
   388  }
   389  
   390  var sgrPattern = regexp.MustCompile("\033\\[[0-9;]*m")
   391  
   392  func stripSGR(bs []byte) []byte      { return sgrPattern.ReplaceAllLiteral(bs, nil) }
   393  func stripSGRString(s string) string { return sgrPattern.ReplaceAllLiteralString(s, "") }
   394  
   395  func normalizeLineEnding(bs []byte) []byte { return bytes.ReplaceAll(bs, []byte("\r\n"), []byte("\n")) }