1// SPDX-License-Identifier: MIT
2
3package expr
4
5import (
6 "compress/gzip"
7 "context"
8 "encoding/json"
9 "errors"
10 "os"
11 "strings"
12 "testing"
13 "unicode/utf8"
14)
15
16// testdata/runner.json.gz holds what the C# runner's expression engine
17// (actions/runner, src/Sdk/DTExpressions2) answers on the corpus of
18// testdata/oracle.json.gz. It is produced by gen/runner. Where the runner and
19// the JavaScript reference disagree, the runner is what users see at run time
20// and this package follows it (see PORTING.md). TestOracle excuses a mismatch
21// with the JavaScript only if this package gives the runner's answer there.
22
23// runnerCommit is the commit of actions/runner that every file recorded from the
24// runner is about. gen/runner/runner-commit.txt holds the same value and the
25// generator scripts refuse a clone at another commit; TestRunnerCommit checks
26// that the two agree and that runner.json.gz was made from it.
27const runnerCommit = "5f0b3ddcc6ee9fe9441e00d55e85b94ea4aad798"
28
29type runnerFile struct {
30 Reference struct {
31 Source string `json:"source"`
32 Commit string `json:"commit"`
33 Runtime string `json:"runtime"`
34 } `json:"reference"`
35 Cases []runnerCase `json:"cases"`
36}
37
38type runnerCase struct {
39 E string `json:"e"`
40 Empty bool `json:"empty"`
41 K string `json:"k"`
42 S string `json:"s"`
43 J string `json:"j"`
44 Err *runnerErr `json:"err"`
45}
46
47// runnerErr is an error of the runner: the phase, the message, and the name of
48// the .NET exception class ("JsonReaderException" for a fromJSON failure).
49type runnerErr struct {
50 T string `json:"t"`
51 M string `json:"m"`
52 X string `json:"x"`
53}
54
55func loadRunner(tb testing.TB) *runnerFile {
56 tb.Helper()
57 f, err := os.Open("testdata/runner.json.gz")
58 if err != nil {
59 tb.Fatal(err)
60 }
61 defer func() { _ = f.Close() }()
62 zr, err := gzip.NewReader(f)
63 if err != nil {
64 tb.Fatal(err)
65 }
66 var rf runnerFile
67 if err := json.NewDecoder(zr).Decode(&rf); err != nil {
68 tb.Fatal(err)
69 }
70 return &rf
71}
72
73// lossy replaces every lone surrogate (WTF-8) by U+FFFD, which is what a JSON
74// decoder makes of the \ud800 escapes in the recorded answers.
75func lossy(s string) string {
76 if !hasWTF8(s) {
77 return s
78 }
79 var b strings.Builder
80 for i := 0; i < len(s); {
81 r, w := decodeRune(s[i:])
82 if r >= 0xD800 && r < 0xE000 {
83 b.WriteRune(utf8.RuneError)
84 } else {
85 b.WriteString(s[i : i+w])
86 }
87 i += w
88 }
89 return b.String()
90}
91
92// goAnswer evaluates e like the runner harness does and returns its answer in
93// the runner's format.
94func goAnswer(e string, ctxNames []string, ec EvalContext) runnerCase {
95 rc := runnerCase{E: e}
96 node, err := Parse(e, ctxNames, oracleFuncInfos)
97 if err != nil {
98 rc.Err = &runnerErr{T: "parse", M: err.Error()}
99 return rc
100 }
101 if node == nil {
102 rc.Empty = true
103 return rc
104 }
105 v, err := Evaluate(context.Background(), node, ec)
106 if err != nil {
107 rc.Err = &runnerErr{T: "eval", M: err.Error(), X: goErrorClass(err)}
108 return rc
109 }
110 rc.K = v.Kind().String()
111 rc.S = lossy(CoerceString(v))
112 rc.J = lossy(ToJSON(v))
113 return rc
114}
115
116// goErrorClass names the kind of an evaluation error of this package in the
117// terms of runnerErr.X: "JsonReaderException" for the failures of fromJSON.
118func goErrorClass(err error) string {
119 var je *JSONError
120 if errors.As(err, &je) {
121 return "JsonReaderException"
122 }
123 return ""
124}
125
126// sameAnswer reports whether a Go answer matches the runner's. Errors match
127// when they are of the same phase and have the same message, parse errors
128// included (token, position and expression), except a fromJSON failure, whose runner message is Json.NET's: it
129// matches only a fromJSON failure of this package (both are JSON reader
130// failures, never just "the expression mentions fromJSON").
131func sameAnswer(g, r runnerCase) bool {
132 if (g.Err == nil) != (r.Err == nil) {
133 return false
134 }
135 if g.Err == nil {
136 return g.Empty == r.Empty && g.K == r.K && g.S == r.S && g.J == r.J
137 }
138 if g.Err.T != r.Err.T {
139 return false
140 }
141 if r.Err.X == "JsonReaderException" || g.Err.X == "JsonReaderException" {
142 return r.Err.X == g.Err.X
143 }
144 return g.Err.M == r.Err.M
145}
146
147func clip(s string) string {
148 if len(s) > 160 {
149 return s[:160] + "..."
150 }
151 return s
152}
153
154// TestSameAnswerFromJSON: an error is excused as "a fromJSON failure" only
155// when both sides are one; an expression that merely mentions fromJSON does not
156// get a free pass.
157func TestSameAnswerFromJSON(t *testing.T) {
158 ev := func(m, x string) runnerCase {
159 return runnerCase{E: "fromJSON('x')", Err: &runnerErr{T: "eval", M: m, X: x}}
160 }
161 reader := ev("Error parsing JSON when evaluating fromJson", "JsonReaderException")
162 runnerReader := ev("Unexpected character encountered while parsing value: x. Path '', line 0, position 0.", "JsonReaderException")
163 format := ev("The following format string is invalid: {", "FormatException")
164 value := runnerCase{E: "fromJSON('1')", K: "Number", S: "1", J: "1"}
165 for _, tc := range []struct {
166 name string
167 g, r runnerCase
168 want bool
169 }{
170 {"both reader failures", reader, runnerReader, true},
171 {"go fails, runner succeeds", reader, value, false},
172 {"runner fails, go succeeds", value, runnerReader, false},
173 {"go reader failure, runner format error", reader, format, false},
174 {"runner reader failure, go format error", format, runnerReader, false},
175 {"same non-reader error", format, format, true},
176 } {
177 if got := sameAnswer(tc.g, tc.r); got != tc.want {
178 t.Errorf("%s: sameAnswer = %v, want %v", tc.name, got, tc.want)
179 }
180 }
181}
182
183func TestRunnerCommit(t *testing.T) {
184 txt, err := os.ReadFile("gen/runner/runner-commit.txt")
185 if err != nil {
186 t.Fatal(err)
187 }
188 if got := strings.TrimSpace(string(txt)); got != runnerCommit {
189 t.Errorf("gen/runner/runner-commit.txt = %q, runnerCommit = %q", got, runnerCommit)
190 }
191 rf := loadRunner(t)
192 if rf.Reference.Commit != runnerCommit {
193 t.Errorf("testdata/runner.json.gz was recorded at %s, want %s", rf.Reference.Commit, runnerCommit)
194 }
195 if !strings.HasPrefix(rf.Reference.Runtime, "Microsoft.NETCore.App 9.0.") {
196 t.Errorf("runtime %q, want Microsoft.NETCore.App 9.0.x", rf.Reference.Runtime)
197 }
198 doc, err := os.ReadFile("PORTING.md")
199 if err != nil {
200 t.Fatal(err)
201 }
202 if !strings.Contains(string(doc), runnerCommit) {
203 t.Errorf("PORTING.md does not mention the runner commit %s", runnerCommit)
204 }
205}