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