detached c82ace7

test(expr): add oracle, golden, table and fuzz tests

commit
c82ace704b75b0631b751618454200640c1d80e8
author
brice · · 2026-10-12 14:46 UTC
parent
7f4d0a4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
84 files · +2300 -0

this diff holds invisible characters (direction controls, zero-width characters). They are shown as <U+XXXX> markers, never applied.

A expr/eval_test.go +951 -0
@@ -0,0 +1,951 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
6+ "errors"
7+ "math"
8+ "strings"
9+ "testing"
10+)
11+
12+type evalCase struct {
13+ expr string
14+ want string // "Kind:string", "PARSE message" or "ERR message"
15+}
16+
17+func runEvalCases(t *testing.T, cases []evalCase) {
18+ t.Helper()
19+ for _, c := range cases {
20+ t.Run(c.expr, func(t *testing.T) {
21+ if got := evalString(t, c.expr); got != c.want {
22+ t.Errorf("%s\n got: %q\nwant: %q", c.expr, got, c.want)
23+ }
24+ })
25+ }
26+}
27+
28+// Examples from the GitHub documentation of expressions.
29+func TestEvalDocumentationExamples(t *testing.T) {
30+ runEvalCases(t, []evalCase{
31+ {"contains('Hello world', 'llo')", "Boolean:true"},
32+ {"contains('Hello world', 'LLO')", "Boolean:true"},
33+ {"contains(github.event.issue.labels.*.name, 'bug')", "Boolean:true"},
34+ {"contains(github.event.issue.labels.*.name, 'BUG')", "Boolean:true"},
35+ {"contains(github.event.issue.labels.*.name, 'nope')", "Boolean:false"},
36+ {"startsWith('Hello world', 'He')", "Boolean:true"},
37+ {"startsWith('Hello world', 'he')", "Boolean:true"},
38+ {"startsWith('Hello world', 'world')", "Boolean:false"},
39+ {"endsWith('Hello world', 'ld')", "Boolean:true"},
40+ {"endsWith('Hello world', 'Hello')", "Boolean:false"},
41+ {"format('Hello {0} {1} {2}', 'Mona', 'the', 'Octocat')", "String:Hello Mona the Octocat"},
42+ {"format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')", "String:{Hello Mona the Octocat!}"},
43+ {"join(github.event.issue.labels.*.name, ', ')", "String:bug, Docs"},
44+ {"join(github.event.issue.labels.*.name)", "String:bug,Docs"},
45+ {"toJSON(fromJSON('{\"a\":1}'))", "String:{\n \"a\": 1\n}"},
46+ {"fromJSON('{\"a\":[1,2]}').a[1]", "Number:2"},
47+ {"fromJSON('true')", "Boolean:true"},
48+ {"fromJSON('\"str\"')", "String:str"},
49+ {"fromJSON('null')", "Null:"},
50+ // The documented idiom for matrix from JSON and for converting a
51+ // string to a number: fromJSON('6') is a number.
52+ {"fromJSON('6') == 6", "Boolean:true"},
53+ {"github.event_name == 'push' && contains(github.ref, 'main')", "Boolean:true"},
54+ {"github.event_name == 'push' || false", "Boolean:true"},
55+ {"!(github.event_name == 'push')", "Boolean:false"},
56+ // Documented ternary idiom: cond && a || b.
57+ {"github.event_name == 'push' && 'yes' || 'no'", "String:yes"},
58+ {"github.event_name == 'pull_request' && 'yes' || 'no'", "String:no"},
59+ // A missing property is null, so the default after || applies.
60+ {"github.event.nope || 'default'", "String:default"},
61+ {"env.MISSING || 'fallback'", "String:fallback"},
62+ {"github.event.commits.*.id", "Array:Array"},
63+ {"join(github.event.commits.*.id, '-')", "String:1-2-3"},
64+ })
65+}
66+
67+func TestEvalLiteralsAndOperators(t *testing.T) {
68+ runEvalCases(t, []evalCase{
69+ {"true", "Boolean:true"},
70+ {"false", "Boolean:false"},
71+ {"null", "Null:"},
72+ {"1", "Number:1"},
73+ {"-1", "Number:-1"},
74+ {"+1", "Number:1"},
75+ {"1.5", "Number:1.5"},
76+ {".5", "Number:0.5"},
77+ {"-.5", "Number:-0.5"},
78+ {"5.", "Number:5"},
79+ {"1e3", "Number:1000"},
80+ {"0x10", "Number:16"},
81+ {"0o17", "Number:15"},
82+ {"0b101", "Number:5"},
83+ {"-0x10", "PARSE Unexpected symbol: '-0x10'. Located at position 1 within expression: -0x10"},
84+ {"NaN", "Number:NaN"},
85+ {"Infinity", "Number:Infinity"},
86+ {"-Infinity", "Number:-Infinity"},
87+ {"'a'", "String:a"},
88+ {"''", "String:"},
89+ {"'it''s'", "String:it's"},
90+ // Only the first doubled quote is unescaped: the reference's
91+ // value.replace("''", "'") is not global.
92+ {"'a''b''c'", "String:a'b''c"},
93+ {"''''", "String:'"},
94+ {"'a''''b'", "String:a'''b"},
95+
96+ {"1 == 1", "Boolean:true"},
97+ {"1 == 2", "Boolean:false"},
98+ {"1 != 2", "Boolean:true"},
99+ {"1 < 2", "Boolean:true"},
100+ {"2 <= 2", "Boolean:true"},
101+ {"2 > 3", "Boolean:false"},
102+ {"3 >= 3", "Boolean:true"},
103+
104+ // Loose equality: strings compare to numbers as numbers, null and
105+ // booleans become numbers.
106+ {"1 == '1'", "Boolean:true"},
107+ {"'1' == 1", "Boolean:true"},
108+ {"'1.0' == 1", "Boolean:true"},
109+ {"' 1 ' == 1", "Boolean:true"},
110+ {"'0x10' == 16", "Boolean:true"},
111+ {"'abc' == 0", "Boolean:false"},
112+ {"'' == 0", "Boolean:true"},
113+ {"'' == null", "Boolean:true"}, // both become 0
114+ {"null == 0", "Boolean:true"},
115+ {"null == false", "Boolean:true"},
116+ {"null == ''", "Boolean:true"},
117+ {"null == null", "Boolean:true"},
118+ {"true == 1", "Boolean:true"},
119+ {"true == '1'", "Boolean:true"},
120+ {"true == 'true'", "Boolean:false"},
121+ {"false == 0", "Boolean:true"},
122+ {"false == ''", "Boolean:true"},
123+ {"true == true", "Boolean:true"},
124+ {"true != false", "Boolean:true"},
125+ {"matrix.nothing == null", "Boolean:true"},
126+ {"env.EMPTY == null", "Boolean:true"},
127+ {"env.EMPTY == false", "Boolean:true"},
128+
129+ // NaN is never equal, never ordered.
130+ {"NaN == NaN", "Boolean:false"},
131+ {"NaN != NaN", "Boolean:true"},
132+ {"NaN < 1", "Boolean:false"},
133+ {"NaN > 1", "Boolean:false"},
134+ {"NaN <= NaN", "Boolean:false"},
135+ {"NaN >= NaN", "Boolean:false"},
136+ {"'abc' > 1", "Boolean:false"},
137+ {"'abc' < 1", "Boolean:false"},
138+ {"'abc' != 1", "Boolean:true"},
139+ {"Infinity == Infinity", "Boolean:true"},
140+ {"-Infinity < Infinity", "Boolean:true"},
141+
142+ // Strings compare case-insensitively and by upper-cased value.
143+ {"'a' == 'A'", "Boolean:true"},
144+ {"'abc' == 'ABC'", "Boolean:true"},
145+ {"'a' < 'B'", "Boolean:true"},
146+ {"'B' > 'a'", "Boolean:true"},
147+ {"'a' < 'A'", "Boolean:false"},
148+ {"'a' <= 'A'", "Boolean:true"},
149+ {"'Z' < 'a'", "Boolean:false"},
150+ {"'_' < 'a'", "Boolean:false"}, // '_' (0x5F) sorts after upper-case letters
151+ {"'ß' == 'SS'", "Boolean:true"},
152+ {"'ß' == 'ss'", "Boolean:true"},
153+ {"'ı' == 'I'", "Boolean:false"}, // dotless i is not upper-cased
154+ {"'ı' == 'i'", "Boolean:false"},
155+ {"'ı' == 'ı'", "Boolean:true"},
156+ {"'İ' == 'i'", "Boolean:false"},
157+ {"'σ' == 'ς'", "Boolean:true"},
158+ {"'dž' == 'DŽ'", "Boolean:true"},
159+
160+ // Booleans order false < true; null does not order (it becomes 0
161+ // against a number but is only equal to itself).
162+ {"false < true", "Boolean:true"},
163+ {"true > false", "Boolean:true"},
164+ {"true < false", "Boolean:false"},
165+ {"true >= true", "Boolean:true"},
166+ {"null < 1", "Boolean:true"},
167+ {"null < null", "Boolean:false"},
168+ {"null <= null", "Boolean:true"},
169+ {"null >= null", "Boolean:true"},
170+ {"true > 0", "Boolean:true"},
171+
172+ // Arrays and dictionaries are equal only by identity and never ordered.
173+ {"matrix.node == matrix.node", "Boolean:true"},
174+ {"matrix.node != matrix.node", "Boolean:false"},
175+ {"matrix.node == fromJSON('[14,16,18]')", "Boolean:false"},
176+ {"matrix.obj == matrix.obj", "Boolean:true"},
177+ {"matrix.obj == fromJSON('{\"k\":\"v\"}')", "Boolean:false"},
178+ {"matrix.node == 1", "Boolean:false"},
179+ {"matrix.node < matrix.node", "Boolean:false"},
180+ {"matrix.node <= matrix.node", "Boolean:true"},
181+ {"matrix.node == null", "Boolean:false"},
182+ {"matrix.obj == true", "Boolean:false"},
183+ {"matrix.obj == 'Object'", "Boolean:false"},
184+ // A filtered array is a new array each time.
185+ {"github.event.commits.* == github.event.commits.*", "Boolean:false"},
186+ })
187+}
188+
189+func TestEvalLogicalReturnsOperands(t *testing.T) {
190+ runEvalCases(t, []evalCase{
191+ {"1 || 2", "Number:1"},
192+ {"0 || 2", "Number:2"},
193+ {"0 || ''", "String:"},
194+ {"0 || '' || null", "Null:"},
195+ {"0 || '' || 'x'", "String:x"},
196+ {"null || null", "Null:"},
197+ {"1 && 2", "Number:2"},
198+ {"0 && 2", "Number:0"},
199+ {"1 && 0 && 3", "Number:0"},
200+ {"1 && 2 && 3", "Number:3"},
201+ {"'a' && 'b' || 'c'", "String:b"},
202+ {"'' && 'b' || 'c'", "String:c"},
203+ {"NaN || 'x'", "String:x"},
204+ {"NaN && 'x'", "Number:NaN"},
205+ {"'0' || 'x'", "String:0"}, // the string "0" is truthy
206+ {"matrix.node || 'x'", "Array:Array"},
207+ {"matrix.obj && 'x'", "String:x"},
208+ {"fromJSON('[]') && 'x'", "String:x"},
209+ {"fromJSON('{}') || 'x'", "Object:Object"},
210+ // Short-circuit: the failing call is never evaluated.
211+ {"true || fromJSON('')", "Boolean:true"},
212+ {"false && fromJSON('')", "Boolean:false"},
213+ {"false || fromJSON('')", "ERR empty input"},
214+ {"true && fromJSON('')", "ERR empty input"},
215+ // Comparisons evaluate both sides.
216+ {"false == fromJSON('')", "ERR empty input"},
217+ {"fromJSON('') == false", "ERR empty input"},
218+ // Precedence: && binds tighter than ||, ! tighter than comparison.
219+ {"true || false && false", "Boolean:true"},
220+ {"(true || false) && false", "Boolean:false"},
221+ {"false && true || true", "Boolean:true"},
222+ {"1 == 1 == true", "Boolean:true"},
223+ {"1 < 2 == true", "Boolean:true"},
224+ {"true == 1 < 2", "Boolean:true"},
225+ {"!1 == 2", "Boolean:false"},
226+ {"!(1 == 2)", "Boolean:true"},
227+ {"!!1", "Boolean:true"},
228+ {"!0", "Boolean:true"},
229+ {"!''", "Boolean:true"},
230+ {"!'0'", "Boolean:false"},
231+ {"!null", "Boolean:true"},
232+ {"!NaN", "Boolean:true"},
233+ {"!matrix.node", "Boolean:false"},
234+ {"!matrix.obj", "Boolean:false"},
235+ {"!github.event.commits.*", "Boolean:false"},
236+ })
237+}
238+
239+func TestEvalAccess(t *testing.T) {
240+ runEvalCases(t, []evalCase{
241+ {"github.event_name", "String:push"},
242+ {"GITHUB.EVENT_NAME", "String:push"},
243+ {"GitHub.Event.Number", "Number:12"},
244+ {"github['event_name']", "String:push"},
245+ {"github['EVENT_NAME']", "String:push"},
246+ {"github[ 'event_name' ]", "String:push"},
247+ {"(github).event_name", "String:push"},
248+ {"(github.event).number", "Number:12"},
249+ {"github.nope", "Null:"},
250+ {"github.nope.deeper", "Null:"},
251+ {"github.nope[0]", "Null:"},
252+ {"github.nope.*", "Array:Array"},
253+ {"github.event.commits[0].id", "String:1"},
254+ {"github.event.commits[2].id", "String:3"},
255+ {"github.event.commits[3]", "Null:"},
256+ {"github.event.commits[-1]", "Null:"},
257+ {"github.event.commits[1.9].id", "String:2"},
258+ {"github.event.commits['1'].id", "String:2"},
259+ {"github.event.commits['1.9'].id", "String:2"},
260+ {"github.event.commits[' 1 '].id", "String:2"},
261+ {"github.event.commits[true].id", "String:2"},
262+ {"github.event.commits[false].id", "String:1"},
263+ {"github.event.commits[null].id", "String:1"},
264+ {"github.event.commits[0x1].id", "String:2"},
265+ {"github.event.commits[NaN]", "Null:"},
266+ {"github.event.commits[Infinity]", "Null:"},
267+ {"github.event.commits['a']", "Null:"},
268+ {"github.event.commits[github]", "Null:"},
269+ {"github.event.commits[0]['author']['name']", "String:Ann"},
270+ {"github.event.commits[0].author.name", "String:Ann"},
271+ {"github.event.commits[2].author.name", "Null:"},
272+ {"github.event.commits[1e20]", "Null:"},
273+ // Index by number or boolean on a dictionary uses the string form.
274+ {"steps[10].outputs.r", "String:ten"},
275+ {"steps['10'].outputs.r", "String:ten"},
276+ // After an identifier, ".10" is not a number: the lexer reads a DOT and then "10.outputs.r" as one number-like word.
277+ {"steps.10.outputs.r", "PARSE Unexpected symbol: '10.outputs.r'. Located at position 7 within expression: steps.10.outputs.r"},
278+ {"steps[2].outputs.r", "String:two"},
279+ {"matrix.obj[0]", "Null:"},
280+ {"matrix.node['0']", "Number:14"},
281+ {"matrix.node[0] == 14", "Boolean:true"},
282+ {"matrix.ver", "String:3.10"},
283+ {"matrix.ver == 3.1", "Boolean:true"},
284+ {"matrix.ver == '3.10'", "Boolean:true"},
285+ // Indexing a non-container gives null (or an empty filter with *).
286+ {"'abc'[0]", "PARSE Unexpected symbol: '['"},
287+ {"(1)[0]", "Null:"},
288+ {"(true).a", "Null:"},
289+ {"(null).a", "Null:"},
290+ {"(1).*", "Array:Array"},
291+ {"toJSON((1).*)", "String:[]"},
292+ // Missing contexts at evaluation time are null.
293+ {"toJSON(github.event.commits[0].added)", "String:[\n \"a.txt\"\n]"},
294+ })
295+}
296+
297+func TestEvalFilters(t *testing.T) {
298+ runEvalCases(t, []evalCase{
299+ {"toJSON(github.event.commits.*.id)", "String:[\n \"1\",\n \"2\",\n \"3\"\n]"},
300+ {"toJSON(github.event.commits[*].id)", "String:[\n \"1\",\n \"2\",\n \"3\"\n]"},
301+ // Elements without the property are skipped.
302+ {"toJSON(github.event.commits.*.author.name)", "String:[\n \"Ann\",\n \"Bob\"\n]"},
303+ // ".*" on a filtered array flattens one level of arrays and objects.
304+ {"toJSON(github.event.commits.*.added.*)", "String:[\n \"a.txt\",\n \"b\",\n \"c\"\n]"},
305+ {"toJSON(github.event.commits.*.added[0])", "String:[\n \"a.txt\",\n \"b\"\n]"},
306+ {"toJSON(github.event.commits.*.added[1])", "String:[\n \"c\"\n]"},
307+ {"toJSON(github.event.nested.*.*)", "String:[\n 1,\n 2,\n 3,\n 4,\n 5,\n 6\n]"},
308+ {"toJSON(github.event.nested.*[1])", "String:[\n 2,\n 5\n]"},
309+ {"toJSON(github.event.nested[*][0])", "String:[\n 1,\n 3,\n 4\n]"},
310+ {"toJSON(github.event.nested.*.*.*)", "String:[]"},
311+ {"toJSON(matrix.obj.*)", "String:[\n \"v\"\n]"},
312+ {"toJSON(matrix.node.*)", "String:[\n 14,\n 16,\n 18\n]"},
313+ {"toJSON(matrix.nothing.*)", "String:[]"},
314+ {"toJSON(inputs.empty.*)", "String:[]"},
315+ {"toJSON(steps.*.outputs.r)", "String:[\n \"two\",\n \"ten\"\n]"},
316+ {"toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":{\"x\":2}}').*.x)", "String:[\n 1,\n 2\n]"},
317+ {"toJSON(fromJSON('[1,2]').*.x)", "String:[]"},
318+ {"toJSON(fromJSON('[{\"a\":[1,2]},{\"a\":[3]}]').*.a.*)", "String:[\n 1,\n 2,\n 3\n]"},
319+ {"contains(github.event.commits.*.message, 'FIX: BUG')", "Boolean:true"},
320+ {"contains(github.event.commits.*.id, 2)", "Boolean:true"},
321+ {"contains(github.event.commits.*.id, 4)", "Boolean:false"},
322+ {"contains(matrix.node, 16)", "Boolean:true"},
323+ {"contains(matrix.node, '16')", "Boolean:true"},
324+ {"contains(matrix.node, 17)", "Boolean:false"},
325+ {"join(matrix.node.*, '.')", "String:14.16.18"},
326+ })
327+}
328+
329+func TestEvalBuiltinFunctions(t *testing.T) {
330+ runEvalCases(t, []evalCase{
331+ // contains
332+ {"contains('abc', 'B')", "Boolean:true"},
333+ {"contains('abc', '')", "Boolean:true"},
334+ {"contains('', '')", "Boolean:true"},
335+ {"contains('', 'a')", "Boolean:false"},
336+ {"contains(null, '')", "Boolean:true"},
337+ {"contains(1234, 23)", "Boolean:true"},
338+ {"contains(true, 'RU')", "Boolean:true"},
339+ {"contains(github, 'a')", "Boolean:false"},
340+ {"contains('a', github)", "Boolean:false"},
341+ {"contains(fromJSON('[]'), null)", "Boolean:false"},
342+ {"contains(fromJSON('[1,\"2\",null]'), null)", "Boolean:true"},
343+ {"contains(fromJSON('[1,\"2\",null]'), '')", "Boolean:true"},
344+ {"contains(fromJSON('[[1]]'), fromJSON('[1]'))", "Boolean:false"},
345+ {"contains('İ', 'i')", "Boolean:true"}, // İ lower-cases to i + U+0307, which contains i
346+ {"contains('ß', 'ss')", "Boolean:false"}, // contains lower-cases: no ß folding
347+ {"contains('A\u03a3', 'a\u03c2')", "Boolean:true"},
348+ {"contains('K', 'k')", "Boolean:true"}, // KELVIN SIGN lower-cases to k
349+ {"contains()", "PARSE Too few parameters supplied: 'contains'"},
350+ {"contains(1)", "PARSE Too few parameters supplied: 'contains'"},
351+ {"contains(1, 2, 3)", "PARSE Too many parameters supplied: 'contains'"},
352+
353+ // startsWith, endsWith
354+ {"startsWith('abc', 'AB')", "Boolean:true"},
355+ {"startsWith('abc', '')", "Boolean:true"},
356+ {"startsWith(null, '')", "Boolean:true"},
357+ {"startsWith(123, 12)", "Boolean:true"},
358+ {"startsWith(github, 'a')", "Boolean:false"},
359+ {"startsWith('a', github)", "Boolean:false"},
360+ {"startsWith('ß', 'ss')", "Boolean:true"}, // upper-cases to SS
361+ {"startsWith('ı', 'I')", "Boolean:false"},
362+ {"endsWith('abc', 'BC')", "Boolean:true"},
363+ {"endsWith('abc', 'abcd')", "Boolean:false"},
364+ {"endsWith(1.50, '5')", "Boolean:true"},
365+ {"endsWith(1.50, 5)", "Boolean:true"},
366+ {"endsWith('abc')", "PARSE Too few parameters supplied: 'endsWith'"},
367+ {"STARTSWITH('abc', 'a')", "Boolean:true"},
368+
369+ // join
370+ {"join('a')", "String:a"},
371+ {"join('a', 'b')", "String:a"},
372+ {"join(null)", "String:"},
373+ {"join(1.50)", "String:1.5"},
374+ {"join(github)", "String:"},
375+ {"join(fromJSON('[]'))", "String:"},
376+ {"join(fromJSON('[1,2,3]'))", "String:1,2,3"},
377+ {"join(fromJSON('[1,2,3]'), '')", "String:123"},
378+ {"join(fromJSON('[1,2,3]'), ', ')", "String:1, 2, 3"},
379+ {"join(fromJSON('[1,2,3]'), 1)", "String:11213"},
380+ {"join(fromJSON('[1,2,3]'), null)", "String:123"},
381+ {"join(fromJSON('[1,2,3]'), github)", "String:1,2,3"}, // a non-primitive separator is ignored
382+ {"join(fromJSON('[null,true,\"a\",1.5,[1],{}]'))", "String:,true,a,1.5,Array,Object"},
383+ {"join()", "PARSE Too few parameters supplied: 'join'"},
384+ {"join(1, 2, 3)", "PARSE Too many parameters supplied: 'join'"},
385+
386+ // toJSON
387+ {"toJSON(null)", "String:null"},
388+ {"toJSON(true)", "String:true"},
389+ {"toJSON(1)", "String:1"},
390+ {"toJSON(NaN)", "String:null"},
391+ {"toJSON(Infinity)", "String:null"},
392+ {"toJSON(-0)", "String:0"},
393+ {"toJSON('x')", "String:\"x\""},
394+ {"toJSON(1e21)", "String:1e+21"},
395+ {"toJSON(1e-7)", "String:1e-7"},
396+ {"toJSON(0.1)", "String:0.1"},
397+ {"toJSON(fromJSON('[]'))", "String:[]"},
398+ {"toJSON(fromJSON('{}'))", "String:{}"},
399+ {"toJSON(fromJSON('[[],{}]'))", "String:[\n [],\n {}\n]"},
400+ {"toJSON(1, 2)", "PARSE Too many parameters supplied: 'toJSON'"},
401+ {"toJSON()", "PARSE Too few parameters supplied: 'toJSON'"},
402+ {"fromJSON(toJSON(github.event.commits[0])).author.name", "String:Ann"},
403+
404+ // fromJSON
405+ {"fromJSON('')", "ERR empty input"},
406+ {"fromJSON(' ')", "ERR empty input"},
407+ {"fromJSON('\n')", "ERR empty input"},
408+ {"fromJSON(null)", "ERR empty input"},
409+ {"fromJSON('{')", "ERR Error parsing JSON when evaluating fromJson"},
410+ {"fromJSON('[1,]')", "ERR Error parsing JSON when evaluating fromJson"},
411+ {"fromJSON('{a:1}')", "ERR Error parsing JSON when evaluating fromJson"},
412+ {"fromJSON('01')", "ERR Error parsing JSON when evaluating fromJson"},
413+ {"fromJSON('NaN')", "ERR Error parsing JSON when evaluating fromJson"},
414+ {"fromJSON('1 2')", "ERR Error parsing JSON when evaluating fromJson"},
415+ {"fromJSON(' 1 ')", "Number:1"},
416+ {"fromJSON('-1.5e2')", "Number:-150"},
417+ {"fromJSON('1e999')", "Number:Infinity"},
418+ {"fromJSON('\"\\u0041\"')", "String:A"},
419+ {"fromJSON('\"\\ud83d\\ude00\"')", "String:😀"},
420+ {"fromJSON(1)", "Number:1"},
421+ {"fromJSON(true)", "Boolean:true"},
422+ {"fromJSON(matrix.node)", "ERR Error parsing JSON when evaluating fromJson"}, // the string "Array"
423+ {"fromJSON(1, 2)", "PARSE Too many parameters supplied: 'fromJSON'"},
424+
425+ // format
426+ {"format('a')", "String:a"},
427+ {"format('')", "String:"},
428+ {"format(null)", "String:"},
429+ {"format(1)", "String:1"},
430+ {"format('{0}', 1)", "String:1"},
431+ {"format('{0}{0}', 'x')", "String:xx"},
432+ {"format('{0}{1}{0}', 'a', 'b')", "String:aba"},
433+ {"format('{{')", "String:{"},
434+ {"format('}}')", "String:}"},
435+ {"format('{{}}')", "String:{}"},
436+ {"format('{{0}}', 1)", "String:{0}"},
437+ {"format('{{{0}}}', 1)", "String:{1}"},
438+ {"format('{0}', 'a', 'b')", "String:a"}, // extra arguments are fine
439+ {"format('{0:x}', 1)", "String:1"}, // a format specifier is skipped
440+ {"format('{0 anything}', 1)", "String:1"},
441+ {"format('{00}', 1)", "String:1"},
442+ {"format('{01}', 1, 2)", "String:2"},
443+ {"format('{0}', null)", "String:"},
444+ {"format('{0}', true)", "String:true"},
445+ {"format('{0}', 0.1)", "String:0.1"},
446+ {"format('{0}', github)", "String:Object"},
447+ {"format('{0}', matrix.node)", "String:Array"},
448+ {"format('{0}-{1}', 1, 'a')", "String:1-a"},
449+ {"format('x{0}y', '😀')", "String:x😀y"},
450+ {"format('{1}', 1)", "ERR The following format string references more arguments than were supplied: {1}"},
451+ {"format('{0}')", "ERR The following format string references more arguments than were supplied: {0}"},
452+ {"format('{', 1)", "ERR The following format string is invalid: {"},
453+ {"format('}', 1)", "ERR The following format string is invalid: }"},
454+ {"format('{}', 1)", "ERR The following format string is invalid: {}"},
455+ {"format('{ 0}', 1)", "ERR The following format string is invalid: { 0}"},
456+ {"format('{-1}', 1)", "ERR The following format string is invalid: {-1}"},
457+ {"format('{a}', 1)", "ERR The following format string is invalid: {a}"},
458+ {"format('{0', 1)", "ERR The following format string is invalid: {0"},
459+ {"format('0}', 1)", "ERR The following format string is invalid: 0}"},
460+ {"format('{{0}', 1)", "ERR The following format string is invalid: {{0}"},
461+ {"format('{0}}', 1)", "ERR The following format string is invalid: {0}}"},
462+ {"format('{12345678901234567890123}', 1)", "ERR The following format string references more arguments than were supplied: {12345678901234567890123}"},
463+ {"format()", "PARSE Too few parameters supplied: 'format'"},
464+
465+ // case
466+ {"case(true, 'a', 'b')", "String:a"},
467+ {"case(false, 'a', 'b')", "String:b"},
468+ {"case(false, 1, true, 2, 3)", "Number:2"},
469+ {"case(false, 1, false, 2, 3)", "Number:3"},
470+ {"case(true, 1, true, 2, 3)", "Number:1"},
471+ {"case(github.event_name == 'push', 'a', github.event_name == 'pr', 'b', 'c')", "String:a"},
472+ {"CASE(true, matrix.node, 2)", "Array:Array"},
473+ {"case(1, 2, 3)", "ERR case predicate must evaluate to a boolean value"},
474+ {"case('true', 2, 3)", "ERR case predicate must evaluate to a boolean value"},
475+ {"case(null, 2, 3)", "ERR case predicate must evaluate to a boolean value"},
476+ {"case(true, 1, 2, 3)", "PARSE Even number of parameters supplied, requires an odd number of parameters: 'case'"},
477+ {"case(true, 1)", "PARSE Too few parameters supplied: 'case'"},
478+ {"case()", "PARSE Too few parameters supplied: 'case'"},
479+ // Predicates after the first true one are not checked...
480+ {"case(true, 1, 'bad', 2, 3)", "Number:1"},
481+ // ...but earlier ones are.
482+ {"case('bad', 1, true, 2, 3)", "ERR case predicate must evaluate to a boolean value"},
483+ // The default is returned as is, whatever it is.
484+ {"case(false, 1, null)", "Null:"},
485+ })
486+}
487+
488+func TestEvalErrorMessages(t *testing.T) {
489+ runEvalCases(t, []evalCase{
490+ {"", "PARSE Unexpected end of expression: 'x'"}, // replaced below: the empty expression is not an error
491+ }[:0])
492+ cases := []evalCase{
493+ {"1 +", "PARSE Unexpected symbol: '+'. Located at position 3 within expression: 1 +"},
494+ {"a b", "PARSE Unrecognized named-value: 'a'"},
495+ {"github github", "PARSE Unexpected symbol: 'github'"},
496+ {"github.event_name ==", "PARSE Unexpected end of expression: '=='"},
497+ {"== 1", "PARSE Unexpected symbol: '=='"},
498+ {"(github.event_name", "PARSE Unexpected end of expression: 'event_name'"},
499+ {"github.event_name)", "PARSE Unexpected symbol: ')'"},
500+ {"contains('a',", "PARSE Unexpected end of expression: ','"},
501+ {"contains('a'", "PARSE Unexpected symbol: 'EOF'"},
502+ {"contains('a' 'b')", "PARSE Unexpected symbol: 'b'"},
503+ {"foo(", "PARSE Unexpected end of expression: '('"},
504+ {"foo()", "PARSE Unrecognized function: 'foo'"},
505+ {"foo", "PARSE Unrecognized named-value: 'foo'"},
506+ {"unknown(1)", "PARSE Unrecognized function: 'unknown'"},
507+ {"success()", "PARSE Unrecognized function: 'success'"}, // not declared in these tests
508+ {"(", "PARSE Unexpected end of expression: '('"},
509+ {")", "PARSE Unexpected symbol: ')'"},
510+ {"()", "PARSE Unexpected symbol: ')'"},
511+ {"!", "PARSE Unexpected end of expression: '!'"},
512+ {"1 !", "PARSE Unexpected symbol: '!'"},
513+ {"[1]", "PARSE Unexpected symbol: '['"},
514+ {"github.[0]", "PARSE Unexpected symbol: '['"},
515+ {"github.", "PARSE Unexpected symbol: 'EOF'"},
516+ {"github[", "PARSE Unexpected end of expression: '['"},
517+ {"github[]", "PARSE Unexpected symbol: ']'"},
518+ {"github[*", "PARSE Unexpected symbol: 'EOF'"},
519+ {"github.true", "Null:"}, // keywords are plain identifiers after a dot
520+ {"env.true", "Null:"},
521+ {"1 = 1", "PARSE Unexpected symbol: '='. Located at position 3 within expression: 1 = 1"},
522+ {"1 & 1", "PARSE Unexpected symbol: '&'. Located at position 3 within expression: 1 & 1"},
523+ {"1 | 1", "PARSE Unexpected symbol: '|'. Located at position 3 within expression: 1 | 1"},
524+ {"1 + 1", "PARSE Unexpected symbol: '+'. Located at position 3 within expression: 1 + 1"},
525+ {"a-b", "PARSE Unrecognized named-value: 'a-b'"},
526+ {"1a", "PARSE Unexpected symbol: '1a'. Located at position 1 within expression: 1a"},
527+ {"'unterminated", "PARSE Unexpected symbol: ''unterminated'. Located at position 1 within expression: 'unterminated"},
528+ {"@", "PARSE Unexpected symbol: '@'. Located at position 1 within expression: @"},
529+ {"${{ 1 }}", "PARSE Unexpected symbol: '${{'. Located at position 1 within expression: ${{ 1 }}"},
530+ {"1 \u00a0 2", "PARSE Unexpected symbol: '\u00a0'. Located at position 3 within expression: 1 \u00a0 2"},
531+ }
532+ runEvalCases(t, cases)
533+}
534+
535+func TestEmptyExpression(t *testing.T) {
536+ for _, s := range []string{"", " ", "\n", " \t\r\n "} {
537+ node, err := Parse(s, nil, nil)
538+ if err != nil || node != nil {
539+ t.Errorf("Parse(%q) = %v, %v; want nil, nil", s, node, err)
540+ }
541+ }
542+ if _, err := Evaluate(nil, EvalContext{}); err == nil {
543+ t.Error("Evaluate(nil) succeeded")
544+ }
545+}
546+
547+func TestParseErrorPositions(t *testing.T) {
548+ tests := []struct {
549+ src string
550+ line, col int
551+ msg string
552+ }{
553+ {"1 ==\n 2 ===\n 3", 1, 5, "Unexpected symbol: '='. Located at position 11 within expression: 1 ==\n 2 ===\n 3"},
554+ {"true &&\n false ||\n nope", 2, 2, "Unrecognized named-value: 'nope'"},
555+ {"\n\n bogus", 2, 2, "Unrecognized named-value: 'bogus'"},
556+ {"github github", 0, 7, "Unexpected symbol: 'github'"},
557+ {" ) ", 0, 2, "Unexpected symbol: ')'"},
558+ {"contains('a',", 0, 12, "Unexpected end of expression: ','"},
559+ {"foo()", 0, 0, "Unrecognized function: 'foo'"},
560+ {"1 && foo()", 0, 5, "Unrecognized function: 'foo'"},
561+ {"'a'\n 'b'\n zzz", 1, 1, "Unexpected symbol: 'b'"},
562+ }
563+ for _, tc := range tests {
564+ _, err := Parse(tc.src, testContextNames, nil)
565+ var pe *Error
566+ if !errors.As(err, &pe) {
567+ t.Errorf("Parse(%q): error %v is not *Error", tc.src, err)
568+ continue
569+ }
570+ if pe.Message != tc.msg || pe.Pos.Line != tc.line || pe.Pos.Column != tc.col {
571+ t.Errorf("Parse(%q) = %q at %v; want %q at %d:%d", tc.src, pe.Message, pe.Pos, tc.msg, tc.line, tc.col)
572+ }
573+ }
574+}
575+
576+func TestParseErrorTypes(t *testing.T) {
577+ tests := []struct {
578+ src string
579+ want ErrorType
580+ }{
581+ {"1 ==", ErrorUnexpectedEndOfExpression},
582+ {")", ErrorUnexpectedSymbol},
583+ {"nope", ErrorUnrecognizedContext},
584+ {"nope()", ErrorUnrecognizedFunction},
585+ {"contains(1)", ErrorTooFewParameters},
586+ {"contains(1,2,3)", ErrorTooManyParameters},
587+ {"case(true, 1, 2, 3)", ErrorEvenParameters},
588+ {strings.Repeat("(", 60) + "1" + strings.Repeat(")", 60), ErrorExceededMaxDepth},
589+ {"1 = 1", ErrorUnexpectedSymbol},
590+ }
591+ for _, tc := range tests {
592+ _, err := Parse(tc.src, nil, nil)
593+ var pe *Error
594+ if !errors.As(err, &pe) || pe.Type != tc.want {
595+ t.Errorf("Parse(%.30q): got %#v, want type %d", tc.src, err, tc.want)
596+ }
597+ }
598+}
599+
600+func TestLimits(t *testing.T) {
601+ deepParens := func(n int) string { return strings.Repeat("(", n) + "1" + strings.Repeat(")", n) }
602+ tests := []struct {
603+ name string
604+ src string
605+ ok bool
606+ }{
607+ // Each "(" costs one level, plus one for the outermost expression.
608+ {"49 parens", deepParens(49), true},
609+ {"50 parens", deepParens(50), false},
610+ {"49 nots", strings.Repeat("!", 49) + "true", true},
611+ {"50 nots", strings.Repeat("!", 50) + "true", false},
612+ {"49 calls", strings.Repeat("format(", 49) + "'x'" + strings.Repeat(")", 49), true},
613+ {"50 calls", strings.Repeat("format(", 50) + "'x'" + strings.Repeat(")", 50), false},
614+ {"long dotted path", "github" + strings.Repeat(".event", 49), true},
615+ {"too long dotted path", "github" + strings.Repeat(".event", 50), false},
616+ {"long flat ||", "1" + strings.Repeat(" || 1", 3000), true},
617+ {"long flat ==", "1" + strings.Repeat(" == 1", 3000), true},
618+ }
619+ for _, tc := range tests {
620+ _, err := Parse(tc.src, testContextNames, nil)
621+ if (err == nil) != tc.ok {
622+ t.Errorf("%s: err = %v, want ok=%v", tc.name, err, tc.ok)
623+ }
624+ var pe *Error
625+ if err != nil && errors.As(err, &pe) && pe.Type == ErrorExceededMaxDepth && pe.Message != "Exceeded max expression depth 50: '1'" && !strings.HasPrefix(pe.Message, "Exceeded max expression depth 50: '") {
626+ t.Errorf("%s: message %q", tc.name, pe.Message)
627+ }
628+ }
629+
630+ // Length: counted in UTF-16 code units, error is the bare token.
631+ long := "'" + strings.Repeat("a", MaxExpressionLength-2) + "'"
632+ if _, err := Parse(long, nil, nil); err != nil {
633+ t.Errorf("expression of exactly %d characters: %v", MaxExpressionLength, err)
634+ }
635+ _, err := Parse(long+" ", nil, nil)
636+ if err == nil || err.Error() != "ErrorExceededMaxLength" {
637+ t.Errorf("expression of %d characters: err = %v", MaxExpressionLength+1, err)
638+ }
639+ // 10501 emoji are 21002 UTF-16 units but only 10501 runes.
640+ emoji := "'" + strings.Repeat("😀", 10500) + "'"
641+ if _, err := Parse(emoji, nil, nil); err == nil {
642+ t.Errorf("a string of 10500 emoji (21002 UTF-16 units) was accepted")
643+ }
644+ emoji = "'" + strings.Repeat("😀", 10499) + "'"
645+ if _, err := Parse(emoji, nil, nil); err != nil {
646+ t.Errorf("a string of 10499 emoji (21000 UTF-16 units) was rejected: %v", err)
647+ }
648+}
649+
650+func TestDepthMessage(t *testing.T) {
651+ _, err := Parse(strings.Repeat("(", 60)+"1"+strings.Repeat(")", 60), nil, nil)
652+ want := "Exceeded max expression depth 50: '('"
653+ if err == nil || err.Error() != want {
654+ t.Errorf("got %v, want %q", err, want)
655+ }
656+}
657+
658+func TestExtensionFunctions(t *testing.T) {
659+ funcs := []FuncInfo{
660+ {Name: "hashFiles", MinArgs: 1, MaxArgs: -1},
661+ {Name: "success", MinArgs: 0, MaxArgs: 0},
662+ {Name: "bounded", MinArgs: 1, MaxArgs: 2},
663+ {Name: "contains", MinArgs: 0, MaxArgs: 0}, // built-ins win for validation
664+ }
665+ for _, c := range []struct{ src, err string }{
666+ {"hashFiles('a')", ""},
667+ {"HASHFILES('a', 'b', 'c', 'd')", ""},
668+ {"hashFiles()", "Too few parameters supplied: 'hashFiles'"},
669+ {"success()", ""},
670+ {"success(1)", "Too many parameters supplied: 'success'"},
671+ {"bounded(1, 2)", ""},
672+ {"bounded(1, 2, 3)", "Too many parameters supplied: 'bounded'"},
673+ {"contains()", "Too few parameters supplied: 'contains'"},
674+ {"nothere()", "Unrecognized function: 'nothere'"},
675+ } {
676+ _, err := Parse(c.src, nil, funcs)
677+ switch {
678+ case c.err == "" && err != nil:
679+ t.Errorf("%s: %v", c.src, err)
680+ case c.err != "" && (err == nil || err.Error() != c.err):
681+ t.Errorf("%s: err = %v, want %q", c.src, err, c.err)
682+ }
683+ }
684+
685+ calls := 0
686+ ctx := EvalContext{
687+ Contexts: map[string]Value{"G": String("v")},
688+ Funcs: map[string]Func{
689+ "HashFiles": func(args []Value) (Value, error) {
690+ calls++
691+ return String("h:" + CoerceString(args[0])), nil
692+ },
693+ "success": func([]Value) (Value, error) { return Boolean(true), nil },
694+ "boom": func([]Value) (Value, error) { return nil, errors.New("kaput") },
695+ // An extra function replaces a built-in of the same name.
696+ "contains": func([]Value) (Value, error) { return String("mine"), nil },
697+ },
698+ }
699+ node, err := Parse("hashFiles(g) == 'h:v' && success() && contains('a','b')", []string{"g"}, funcs)
700+ if err != nil {
701+ t.Fatal(err)
702+ }
703+ v, err := Evaluate(node, ctx)
704+ if err != nil || CoerceString(v) != "mine" || calls != 1 {
705+ t.Errorf("got %v, %v, %d calls", v, err, calls)
706+ }
707+ node, err = Parse("boom()", nil, []FuncInfo{{Name: "boom"}})
708+ if err != nil {
709+ t.Fatal(err)
710+ }
711+ if _, err = Evaluate(node, ctx); err == nil || err.Error() != "kaput" {
712+ t.Errorf("function error not propagated: %v", err)
713+ }
714+ // A declared function without an implementation is an evaluation error.
715+ node, _ = Parse("success()", nil, funcs)
716+ if _, err = Evaluate(node, EvalContext{}); err == nil {
717+ t.Error("calling an unimplemented function succeeded")
718+ }
719+}
720+
721+func TestContextNames(t *testing.T) {
722+ node, err := Parse("GitHub.Ref", []string{"github"}, nil)
723+ if err != nil {
724+ t.Fatal(err)
725+ }
726+ for _, key := range []string{"github", "GITHUB", "GitHub"} {
727+ d := NewDictionary()
728+ d.Add("ref", String("main"))
729+ v, err := Evaluate(node, EvalContext{Contexts: map[string]Value{key: d}})
730+ if err != nil || CoerceString(v) != "main" {
731+ t.Errorf("context key %q: %v, %v", key, v, err)
732+ }
733+ }
734+ // A declared but absent context is null.
735+ v, err := Evaluate(node, EvalContext{})
736+ if err != nil || v.Kind() != KindNull {
737+ t.Errorf("absent context: %v, %v", v, err)
738+ }
739+ // Names that were not declared to Parse are errors, even if present at evaluation.
740+ if _, err := Parse("secrets.X", []string{"github"}, nil); err == nil || err.Error() != "Unrecognized named-value: 'secrets'" {
741+ t.Errorf("undeclared context: %v", err)
742+ }
743+ // A nil Value in a context behaves as null.
744+ node, _ = Parse("github.a.b", []string{"github"}, nil)
745+ if v, err := Evaluate(node, EvalContext{Contexts: map[string]Value{"github": nil}}); err != nil || v.Kind() != KindNull {
746+ t.Errorf("nil context value: %v, %v", v, err)
747+ }
748+ var nilDict *Dictionary
749+ var nilArr *Array
750+ node, _ = Parse("(github)[0] || x", []string{"github", "x"}, nil)
751+ for _, bad := range []Value{nilDict, nilArr} {
752+ if _, err := Evaluate(node, EvalContext{Contexts: map[string]Value{"github": bad}}); err != nil {
753+ t.Errorf("typed nil %T: %v", bad, err)
754+ }
755+ }
756+}
757+
758+func TestEvaluateDoesNotMutateContext(t *testing.T) {
759+ ctxs := testContexts(t)
760+ before := map[string]string{}
761+ for k, v := range ctxs {
762+ before[k] = ToJSON(v)
763+ }
764+ for _, src := range []string{"github.event.commits.*", "github.event.commits.*.added.*", "github.*", "matrix.node.*", "toJSON(steps.*)"} {
765+ node, err := Parse(src, testContextNames, nil)
766+ if err != nil {
767+ t.Fatal(err)
768+ }
769+ v, err := Evaluate(node, EvalContext{Contexts: ctxs})
770+ if err != nil {
771+ t.Fatal(err)
772+ }
773+ if a, ok := v.(*Array); ok {
774+ a.Append(String("tampered"))
775+ }
776+ }
777+ for k, v := range ctxs {
778+ if ToJSON(v) != before[k] {
779+ t.Errorf("context %s was modified", k)
780+ }
781+ }
782+}
783+
784+func TestFloatCoercion(t *testing.T) {
785+ tests := []struct {
786+ in float64
787+ want string
788+ }{
789+ {0, "0"},
790+ {math.Copysign(0, -1), "0"},
791+ {1, "1"},
792+ {-1, "-1"},
793+ {1.5, "1.5"},
794+ {0.1, "0.1"},
795+ {0.30000000000000004, "0.3"},
796+ {1.005, "1.005"},
797+ {100, "100"},
798+ {1e20, "100000000000000000000"},
799+ {1e21, "1e+21"},
800+ {123456789012345680000, "123456789012345680000"},
801+ {1e-7, "1e-7"}, // toFixed(15) keeps 0.000000100000000, which prints as 1e-7
802+ {0.000001, "0.000001"},
803+ {5e-16, "1e-15"}, // toFixed rounds half up on the exact binary value
804+ {1e-15, "1e-15"},
805+ {1e-16, "0"},
806+ {-1e-16, "0"},
807+ {12345678901234567890, "12345678901234567000"},
808+ {math.NaN(), "NaN"},
809+ {math.Inf(1), "Infinity"},
810+ {math.Inf(-1), "-Infinity"},
811+ {2.5, "2.5"},
812+ {-0.25, "-0.25"},
813+ {4503599627370497.5, "4503599627370498"}, // the literal itself rounds to the nearest double
814+ {1.7976931348623157e308, "1.7976931348623157e+308"},
815+ {5e-324, "0"},
816+ }
817+ for _, tc := range tests {
818+ if got := CoerceString(Number(tc.in)); got != tc.want {
819+ t.Errorf("CoerceString(%v) = %q, want %q", tc.in, got, tc.want)
820+ }
821+ }
822+}
823+
824+func TestJSNumberToString(t *testing.T) {
825+ one, two := 0.1, 0.2
826+ sum := one + two // not a constant expression: Go would fold 0.1+0.2 exactly
827+ tests := []struct {
828+ in float64
829+ want string
830+ }{
831+ {0, "0"}, {1, "1"}, {-1.5, "-1.5"}, {100, "100"}, {1e20, "100000000000000000000"}, {1e21, "1e+21"},
832+ {1.5e21, "1.5e+21"}, {1e-6, "0.000001"}, {1e-7, "1e-7"}, {1.5e-7, "1.5e-7"}, {123456.789, "123456.789"},
833+ {0.000123, "0.000123"}, {5e-324, "5e-324"}, {1.7976931348623157e308, "1.7976931348623157e+308"},
834+ {-1e-7, "-1e-7"}, {sum, "0.30000000000000004"}, {math.NaN(), "NaN"}, {math.Inf(-1), "-Infinity"},
835+ }
836+ for _, tc := range tests {
837+ if got := jsNumberToString(tc.in); got != tc.want {
838+ t.Errorf("jsNumberToString(%v) = %q, want %q", tc.in, got, tc.want)
839+ }
840+ }
841+}
842+
843+func TestJSToNumber(t *testing.T) {
844+ nan := math.NaN()
845+ tests := []struct {
846+ in string
847+ want float64
848+ }{
849+ {"", 0}, {" ", 0}, {"\n\t", 0}, {"1", 1}, {" 1 ", 1}, {"\u00a01\u2003", 1}, {"\ufeff1", 1}, {"+1", 1}, {"-1", -1},
850+ {"1.", 1}, {".5", 0.5}, {"-.5", -0.5}, {"1e3", 1000}, {"1E-3", 0.001}, {"1e+3", 1000}, {"0x10", 16}, {"0X1f", 31},
851+ {"0o17", 15}, {"0b101", 5}, {"0x", nan}, {"0xg", nan}, {"-0x10", nan}, {"+0x10", nan}, {"0b2", nan}, {"1_000", nan},
852+ {"Infinity", math.Inf(1)}, {"-Infinity", math.Inf(-1)}, {"+Infinity", math.Inf(1)}, {"infinity", nan}, {"inf", nan},
853+ {"NaN", nan}, {"nan", nan}, {"abc", nan}, {"1e", nan}, {"1e+", nan}, {".", nan}, {"e5", nan}, {"1 2", nan}, {"1,5", nan},
854+ {"00", 0}, {"007", 7}, {"0.0", 0}, {"1e999", math.Inf(1)}, {"-1e999", math.Inf(-1)}, {"0x1fffffffffffff", 9007199254740991},
855+ {"12345678901234567890", 12345678901234567890}, {"٣", nan}, {"--1", nan}, {"+-1", nan}, {"1e1.5", nan}, {"0x1.8", nan},
856+ }
857+ for _, tc := range tests {
858+ got := jsToNumber(tc.in)
859+ if got != tc.want && !(math.IsNaN(got) && math.IsNaN(tc.want)) {
860+ t.Errorf("jsToNumber(%q) = %v, want %v", tc.in, got, tc.want)
861+ }
862+ }
863+}
864+
865+func TestJSCasing(t *testing.T) {
866+ upper := map[string]string{"abc": "ABC", "ß": "SS", "ʼn": "ʼN", "dž": "DŽ", "ı": "I", "ffi": "FFI", "ὒ": "Υ̓̀", "ᾀ": "ἈΙ", "σ": "Σ", "ς": "Σ", "😀": "😀", "é": "É"}
867+ for in, want := range upper {
868+ if got := jsToUpper(in); got != want {
869+ t.Errorf("jsToUpper(%q) = %q, want %q", in, got, want)
870+ }
871+ }
872+ special := map[string]string{"ı": "ı", "ıi": "ıI", "I": "I", "ß": "SS", "aıb": "AıB"}
873+ for in, want := range special {
874+ if got := toUpperSpecial(in); got != want {
875+ t.Errorf("toUpperSpecial(%q) = %q, want %q", in, got, want)
876+ }
877+ }
878+ lower := map[string]string{
879+ "ABC": "abc", "İ": "i\u0307", "ΑΣ": "ας", "ΑΣΑ": "ασα", "Α Σ": "α σ", "ΑΣ.": "ας.", "Σ": "σ", "ΑΣ'Α": "ασ'α",
880+ "K": "k", "Å": "å", "😀": "😀", "DŽ": "dž", "ΑΣΣ": "ασς", "Α.Σ": "α.ς",
881+ }
882+ for in, want := range lower {
883+ if got := jsToLower(in); got != want {
884+ t.Errorf("jsToLower(%q) = %q, want %q", in, got, want)
885+ }
886+ }
887+}
888+
889+func TestCompareUTF16(t *testing.T) {
890+ // U+10000 is the surrogate pair D800 DC00, which sorts before U+E000.
891+ if got := compareUTF16("\U00010000", "\ue000"); got >= 0 {
892+ t.Errorf("compareUTF16(U+10000, U+E000) = %d, want < 0", got)
893+ }
894+ if got := compareUTF16("\uffff", "\U00010000"); got <= 0 {
895+ t.Errorf("compareUTF16(U+FFFF, U+10000) = %d, want > 0", got)
896+ }
897+ for _, c := range []struct {
898+ a, b string
899+ want int
900+ }{{"a", "a", 0}, {"a", "b", -1}, {"b", "a", 1}, {"a", "ab", -1}, {"ab", "a", 1}, {"", "", 0}, {"", "a", -1}} {
901+ if got := compareUTF16(c.a, c.b); got != c.want {
902+ t.Errorf("compareUTF16(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
903+ }
904+ }
905+ // And through the operators.
906+ for _, c := range evalCasesUTF16 {
907+ if got := evalString(t, c.expr); got != c.want {
908+ t.Errorf("%s = %s, want %s", c.expr, got, c.want)
909+ }
910+ }
911+}
912+
913+var evalCasesUTF16 = []evalCase{
914+ {"'\U00010000' < '\ue000'", "Boolean:true"},
915+ {"'\ue000' > '\U00010000'", "Boolean:true"},
916+ {"'\U00010000' >= '\ue000'", "Boolean:false"},
917+}
918+
919+func TestTokenString(t *testing.T) {
920+ toks, err := Lex("a.b == 'x''y' && 1.5")
921+ if err != nil {
922+ t.Fatal(err)
923+ }
924+ var got []string
925+ for _, tk := range toks {
926+ got = append(got, tk.Type.String())
927+ }
928+ want := "IDENTIFIER DOT IDENTIFIER EQUAL_EQUAL STRING AND NUMBER EOF"
929+ if strings.Join(got, " ") != want {
930+ t.Errorf("tokens = %v, want %s", got, want)
931+ }
932+ if toks[4].Value.(String) != "x'y" || toks[4].Lexeme != "'x''y'" {
933+ t.Errorf("string token = %#v", toks[4])
934+ }
935+ if TokenType(99).String() != "TokenType(99)" {
936+ t.Error("unknown token type")
937+ }
938+}
939+
940+// The EOF token of the reference is positioned from a stale token start, so
941+// after a trailing newline its column is negative. Kept as is.
942+func TestEOFTokenPosition(t *testing.T) {
943+ toks, err := Lex("a\n")
944+ if err != nil {
945+ t.Fatal(err)
946+ }
947+ eof := toks[len(toks)-1]
948+ if eof.Type != TokenEOF || eof.Range.Start != (Position{Line: 1, Column: -1}) || eof.Range.End != (Position{Line: 1, Column: 0}) {
949+ t.Errorf("EOF token = %+v", eof)
950+ }
951+}
@@ -0,0 +1,951 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
6+ "errors"
7+ "math"
8+ "strings"
9+ "testing"
10+)
11+
12+type evalCase struct {
13+ expr string
14+ want string // "Kind:string", "PARSE message" or "ERR message"
15+}
16+
17+func runEvalCases(t *testing.T, cases []evalCase) {
18+ t.Helper()
19+ for _, c := range cases {
20+ t.Run(c.expr, func(t *testing.T) {
21+ if got := evalString(t, c.expr); got != c.want {
22+ t.Errorf("%s\n got: %q\nwant: %q", c.expr, got, c.want)
23+ }
24+ })
25+ }
26+}
27+
28+// Examples from the GitHub documentation of expressions.
29+func TestEvalDocumentationExamples(t *testing.T) {
30+ runEvalCases(t, []evalCase{
31+ {"contains('Hello world', 'llo')", "Boolean:true"},
32+ {"contains('Hello world', 'LLO')", "Boolean:true"},
33+ {"contains(github.event.issue.labels.*.name, 'bug')", "Boolean:true"},
34+ {"contains(github.event.issue.labels.*.name, 'BUG')", "Boolean:true"},
35+ {"contains(github.event.issue.labels.*.name, 'nope')", "Boolean:false"},
36+ {"startsWith('Hello world', 'He')", "Boolean:true"},
37+ {"startsWith('Hello world', 'he')", "Boolean:true"},
38+ {"startsWith('Hello world', 'world')", "Boolean:false"},
39+ {"endsWith('Hello world', 'ld')", "Boolean:true"},
40+ {"endsWith('Hello world', 'Hello')", "Boolean:false"},
41+ {"format('Hello {0} {1} {2}', 'Mona', 'the', 'Octocat')", "String:Hello Mona the Octocat"},
42+ {"format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')", "String:{Hello Mona the Octocat!}"},
43+ {"join(github.event.issue.labels.*.name, ', ')", "String:bug, Docs"},
44+ {"join(github.event.issue.labels.*.name)", "String:bug,Docs"},
45+ {"toJSON(fromJSON('{\"a\":1}'))", "String:{\n \"a\": 1\n}"},
46+ {"fromJSON('{\"a\":[1,2]}').a[1]", "Number:2"},
47+ {"fromJSON('true')", "Boolean:true"},
48+ {"fromJSON('\"str\"')", "String:str"},
49+ {"fromJSON('null')", "Null:"},
50+ // The documented idiom for matrix from JSON and for converting a
51+ // string to a number: fromJSON('6') is a number.
52+ {"fromJSON('6') == 6", "Boolean:true"},
53+ {"github.event_name == 'push' && contains(github.ref, 'main')", "Boolean:true"},
54+ {"github.event_name == 'push' || false", "Boolean:true"},
55+ {"!(github.event_name == 'push')", "Boolean:false"},
56+ // Documented ternary idiom: cond && a || b.
57+ {"github.event_name == 'push' && 'yes' || 'no'", "String:yes"},
58+ {"github.event_name == 'pull_request' && 'yes' || 'no'", "String:no"},
59+ // A missing property is null, so the default after || applies.
60+ {"github.event.nope || 'default'", "String:default"},
61+ {"env.MISSING || 'fallback'", "String:fallback"},
62+ {"github.event.commits.*.id", "Array:Array"},
63+ {"join(github.event.commits.*.id, '-')", "String:1-2-3"},
64+ })
65+}
66+
67+func TestEvalLiteralsAndOperators(t *testing.T) {
68+ runEvalCases(t, []evalCase{
69+ {"true", "Boolean:true"},
70+ {"false", "Boolean:false"},
71+ {"null", "Null:"},
72+ {"1", "Number:1"},
73+ {"-1", "Number:-1"},
74+ {"+1", "Number:1"},
75+ {"1.5", "Number:1.5"},
76+ {".5", "Number:0.5"},
77+ {"-.5", "Number:-0.5"},
78+ {"5.", "Number:5"},
79+ {"1e3", "Number:1000"},
80+ {"0x10", "Number:16"},
81+ {"0o17", "Number:15"},
82+ {"0b101", "Number:5"},
83+ {"-0x10", "PARSE Unexpected symbol: '-0x10'. Located at position 1 within expression: -0x10"},
84+ {"NaN", "Number:NaN"},
85+ {"Infinity", "Number:Infinity"},
86+ {"-Infinity", "Number:-Infinity"},
87+ {"'a'", "String:a"},
88+ {"''", "String:"},
89+ {"'it''s'", "String:it's"},
90+ // Only the first doubled quote is unescaped: the reference's
91+ // value.replace("''", "'") is not global.
92+ {"'a''b''c'", "String:a'b''c"},
93+ {"''''", "String:'"},
94+ {"'a''''b'", "String:a'''b"},
95+
96+ {"1 == 1", "Boolean:true"},
97+ {"1 == 2", "Boolean:false"},
98+ {"1 != 2", "Boolean:true"},
99+ {"1 < 2", "Boolean:true"},
100+ {"2 <= 2", "Boolean:true"},
101+ {"2 > 3", "Boolean:false"},
102+ {"3 >= 3", "Boolean:true"},
103+
104+ // Loose equality: strings compare to numbers as numbers, null and
105+ // booleans become numbers.
106+ {"1 == '1'", "Boolean:true"},
107+ {"'1' == 1", "Boolean:true"},
108+ {"'1.0' == 1", "Boolean:true"},
109+ {"' 1 ' == 1", "Boolean:true"},
110+ {"'0x10' == 16", "Boolean:true"},
111+ {"'abc' == 0", "Boolean:false"},
112+ {"'' == 0", "Boolean:true"},
113+ {"'' == null", "Boolean:true"}, // both become 0
114+ {"null == 0", "Boolean:true"},
115+ {"null == false", "Boolean:true"},
116+ {"null == ''", "Boolean:true"},
117+ {"null == null", "Boolean:true"},
118+ {"true == 1", "Boolean:true"},
119+ {"true == '1'", "Boolean:true"},
120+ {"true == 'true'", "Boolean:false"},
121+ {"false == 0", "Boolean:true"},
122+ {"false == ''", "Boolean:true"},
123+ {"true == true", "Boolean:true"},
124+ {"true != false", "Boolean:true"},
125+ {"matrix.nothing == null", "Boolean:true"},
126+ {"env.EMPTY == null", "Boolean:true"},
127+ {"env.EMPTY == false", "Boolean:true"},
128+
129+ // NaN is never equal, never ordered.
130+ {"NaN == NaN", "Boolean:false"},
131+ {"NaN != NaN", "Boolean:true"},
132+ {"NaN < 1", "Boolean:false"},
133+ {"NaN > 1", "Boolean:false"},
134+ {"NaN <= NaN", "Boolean:false"},
135+ {"NaN >= NaN", "Boolean:false"},
136+ {"'abc' > 1", "Boolean:false"},
137+ {"'abc' < 1", "Boolean:false"},
138+ {"'abc' != 1", "Boolean:true"},
139+ {"Infinity == Infinity", "Boolean:true"},
140+ {"-Infinity < Infinity", "Boolean:true"},
141+
142+ // Strings compare case-insensitively and by upper-cased value.
143+ {"'a' == 'A'", "Boolean:true"},
144+ {"'abc' == 'ABC'", "Boolean:true"},
145+ {"'a' < 'B'", "Boolean:true"},
146+ {"'B' > 'a'", "Boolean:true"},
147+ {"'a' < 'A'", "Boolean:false"},
148+ {"'a' <= 'A'", "Boolean:true"},
149+ {"'Z' < 'a'", "Boolean:false"},
150+ {"'_' < 'a'", "Boolean:false"}, // '_' (0x5F) sorts after upper-case letters
151+ {"'ß' == 'SS'", "Boolean:true"},
152+ {"'ß' == 'ss'", "Boolean:true"},
153+ {"'ı' == 'I'", "Boolean:false"}, // dotless i is not upper-cased
154+ {"'ı' == 'i'", "Boolean:false"},
155+ {"'ı' == 'ı'", "Boolean:true"},
156+ {"'İ' == 'i'", "Boolean:false"},
157+ {"'σ' == 'ς'", "Boolean:true"},
158+ {"'dž' == 'DŽ'", "Boolean:true"},
159+
160+ // Booleans order false < true; null does not order (it becomes 0
161+ // against a number but is only equal to itself).
162+ {"false < true", "Boolean:true"},
163+ {"true > false", "Boolean:true"},
164+ {"true < false", "Boolean:false"},
165+ {"true >= true", "Boolean:true"},
166+ {"null < 1", "Boolean:true"},
167+ {"null < null", "Boolean:false"},
168+ {"null <= null", "Boolean:true"},
169+ {"null >= null", "Boolean:true"},
170+ {"true > 0", "Boolean:true"},
171+
172+ // Arrays and dictionaries are equal only by identity and never ordered.
173+ {"matrix.node == matrix.node", "Boolean:true"},
174+ {"matrix.node != matrix.node", "Boolean:false"},
175+ {"matrix.node == fromJSON('[14,16,18]')", "Boolean:false"},
176+ {"matrix.obj == matrix.obj", "Boolean:true"},
177+ {"matrix.obj == fromJSON('{\"k\":\"v\"}')", "Boolean:false"},
178+ {"matrix.node == 1", "Boolean:false"},
179+ {"matrix.node < matrix.node", "Boolean:false"},
180+ {"matrix.node <= matrix.node", "Boolean:true"},
181+ {"matrix.node == null", "Boolean:false"},
182+ {"matrix.obj == true", "Boolean:false"},
183+ {"matrix.obj == 'Object'", "Boolean:false"},
184+ // A filtered array is a new array each time.
185+ {"github.event.commits.* == github.event.commits.*", "Boolean:false"},
186+ })
187+}
188+
189+func TestEvalLogicalReturnsOperands(t *testing.T) {
190+ runEvalCases(t, []evalCase{
191+ {"1 || 2", "Number:1"},
192+ {"0 || 2", "Number:2"},
193+ {"0 || ''", "String:"},
194+ {"0 || '' || null", "Null:"},
195+ {"0 || '' || 'x'", "String:x"},
196+ {"null || null", "Null:"},
197+ {"1 && 2", "Number:2"},
198+ {"0 && 2", "Number:0"},
199+ {"1 && 0 && 3", "Number:0"},
200+ {"1 && 2 && 3", "Number:3"},
201+ {"'a' && 'b' || 'c'", "String:b"},
202+ {"'' && 'b' || 'c'", "String:c"},
203+ {"NaN || 'x'", "String:x"},
204+ {"NaN && 'x'", "Number:NaN"},
205+ {"'0' || 'x'", "String:0"}, // the string "0" is truthy
206+ {"matrix.node || 'x'", "Array:Array"},
207+ {"matrix.obj && 'x'", "String:x"},
208+ {"fromJSON('[]') && 'x'", "String:x"},
209+ {"fromJSON('{}') || 'x'", "Object:Object"},
210+ // Short-circuit: the failing call is never evaluated.
211+ {"true || fromJSON('')", "Boolean:true"},
212+ {"false && fromJSON('')", "Boolean:false"},
213+ {"false || fromJSON('')", "ERR empty input"},
214+ {"true && fromJSON('')", "ERR empty input"},
215+ // Comparisons evaluate both sides.
216+ {"false == fromJSON('')", "ERR empty input"},
217+ {"fromJSON('') == false", "ERR empty input"},
218+ // Precedence: && binds tighter than ||, ! tighter than comparison.
219+ {"true || false && false", "Boolean:true"},
220+ {"(true || false) && false", "Boolean:false"},
221+ {"false && true || true", "Boolean:true"},
222+ {"1 == 1 == true", "Boolean:true"},
223+ {"1 < 2 == true", "Boolean:true"},
224+ {"true == 1 < 2", "Boolean:true"},
225+ {"!1 == 2", "Boolean:false"},
226+ {"!(1 == 2)", "Boolean:true"},
227+ {"!!1", "Boolean:true"},
228+ {"!0", "Boolean:true"},
229+ {"!''", "Boolean:true"},
230+ {"!'0'", "Boolean:false"},
231+ {"!null", "Boolean:true"},
232+ {"!NaN", "Boolean:true"},
233+ {"!matrix.node", "Boolean:false"},
234+ {"!matrix.obj", "Boolean:false"},
235+ {"!github.event.commits.*", "Boolean:false"},
236+ })
237+}
238+
239+func TestEvalAccess(t *testing.T) {
240+ runEvalCases(t, []evalCase{
241+ {"github.event_name", "String:push"},
242+ {"GITHUB.EVENT_NAME", "String:push"},
243+ {"GitHub.Event.Number", "Number:12"},
244+ {"github['event_name']", "String:push"},
245+ {"github['EVENT_NAME']", "String:push"},
246+ {"github[ 'event_name' ]", "String:push"},
247+ {"(github).event_name", "String:push"},
248+ {"(github.event).number", "Number:12"},
249+ {"github.nope", "Null:"},
250+ {"github.nope.deeper", "Null:"},
251+ {"github.nope[0]", "Null:"},
252+ {"github.nope.*", "Array:Array"},
253+ {"github.event.commits[0].id", "String:1"},
254+ {"github.event.commits[2].id", "String:3"},
255+ {"github.event.commits[3]", "Null:"},
256+ {"github.event.commits[-1]", "Null:"},
257+ {"github.event.commits[1.9].id", "String:2"},
258+ {"github.event.commits['1'].id", "String:2"},
259+ {"github.event.commits['1.9'].id", "String:2"},
260+ {"github.event.commits[' 1 '].id", "String:2"},
261+ {"github.event.commits[true].id", "String:2"},
262+ {"github.event.commits[false].id", "String:1"},
263+ {"github.event.commits[null].id", "String:1"},
264+ {"github.event.commits[0x1].id", "String:2"},
265+ {"github.event.commits[NaN]", "Null:"},
266+ {"github.event.commits[Infinity]", "Null:"},
267+ {"github.event.commits['a']", "Null:"},
268+ {"github.event.commits[github]", "Null:"},
269+ {"github.event.commits[0]['author']['name']", "String:Ann"},
270+ {"github.event.commits[0].author.name", "String:Ann"},
271+ {"github.event.commits[2].author.name", "Null:"},
272+ {"github.event.commits[1e20]", "Null:"},
273+ // Index by number or boolean on a dictionary uses the string form.
274+ {"steps[10].outputs.r", "String:ten"},
275+ {"steps['10'].outputs.r", "String:ten"},
276+ // After an identifier, ".10" is not a number: the lexer reads a DOT and then "10.outputs.r" as one number-like word.
277+ {"steps.10.outputs.r", "PARSE Unexpected symbol: '10.outputs.r'. Located at position 7 within expression: steps.10.outputs.r"},
278+ {"steps[2].outputs.r", "String:two"},
279+ {"matrix.obj[0]", "Null:"},
280+ {"matrix.node['0']", "Number:14"},
281+ {"matrix.node[0] == 14", "Boolean:true"},
282+ {"matrix.ver", "String:3.10"},
283+ {"matrix.ver == 3.1", "Boolean:true"},
284+ {"matrix.ver == '3.10'", "Boolean:true"},
285+ // Indexing a non-container gives null (or an empty filter with *).
286+ {"'abc'[0]", "PARSE Unexpected symbol: '['"},
287+ {"(1)[0]", "Null:"},
288+ {"(true).a", "Null:"},
289+ {"(null).a", "Null:"},
290+ {"(1).*", "Array:Array"},
291+ {"toJSON((1).*)", "String:[]"},
292+ // Missing contexts at evaluation time are null.
293+ {"toJSON(github.event.commits[0].added)", "String:[\n \"a.txt\"\n]"},
294+ })
295+}
296+
297+func TestEvalFilters(t *testing.T) {
298+ runEvalCases(t, []evalCase{
299+ {"toJSON(github.event.commits.*.id)", "String:[\n \"1\",\n \"2\",\n \"3\"\n]"},
300+ {"toJSON(github.event.commits[*].id)", "String:[\n \"1\",\n \"2\",\n \"3\"\n]"},
301+ // Elements without the property are skipped.
302+ {"toJSON(github.event.commits.*.author.name)", "String:[\n \"Ann\",\n \"Bob\"\n]"},
303+ // ".*" on a filtered array flattens one level of arrays and objects.
304+ {"toJSON(github.event.commits.*.added.*)", "String:[\n \"a.txt\",\n \"b\",\n \"c\"\n]"},
305+ {"toJSON(github.event.commits.*.added[0])", "String:[\n \"a.txt\",\n \"b\"\n]"},
306+ {"toJSON(github.event.commits.*.added[1])", "String:[\n \"c\"\n]"},
307+ {"toJSON(github.event.nested.*.*)", "String:[\n 1,\n 2,\n 3,\n 4,\n 5,\n 6\n]"},
308+ {"toJSON(github.event.nested.*[1])", "String:[\n 2,\n 5\n]"},
309+ {"toJSON(github.event.nested[*][0])", "String:[\n 1,\n 3,\n 4\n]"},
310+ {"toJSON(github.event.nested.*.*.*)", "String:[]"},
311+ {"toJSON(matrix.obj.*)", "String:[\n \"v\"\n]"},
312+ {"toJSON(matrix.node.*)", "String:[\n 14,\n 16,\n 18\n]"},
313+ {"toJSON(matrix.nothing.*)", "String:[]"},
314+ {"toJSON(inputs.empty.*)", "String:[]"},
315+ {"toJSON(steps.*.outputs.r)", "String:[\n \"two\",\n \"ten\"\n]"},
316+ {"toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":{\"x\":2}}').*.x)", "String:[\n 1,\n 2\n]"},
317+ {"toJSON(fromJSON('[1,2]').*.x)", "String:[]"},
318+ {"toJSON(fromJSON('[{\"a\":[1,2]},{\"a\":[3]}]').*.a.*)", "String:[\n 1,\n 2,\n 3\n]"},
319+ {"contains(github.event.commits.*.message, 'FIX: BUG')", "Boolean:true"},
320+ {"contains(github.event.commits.*.id, 2)", "Boolean:true"},
321+ {"contains(github.event.commits.*.id, 4)", "Boolean:false"},
322+ {"contains(matrix.node, 16)", "Boolean:true"},
323+ {"contains(matrix.node, '16')", "Boolean:true"},
324+ {"contains(matrix.node, 17)", "Boolean:false"},
325+ {"join(matrix.node.*, '.')", "String:14.16.18"},
326+ })
327+}
328+
329+func TestEvalBuiltinFunctions(t *testing.T) {
330+ runEvalCases(t, []evalCase{
331+ // contains
332+ {"contains('abc', 'B')", "Boolean:true"},
333+ {"contains('abc', '')", "Boolean:true"},
334+ {"contains('', '')", "Boolean:true"},
335+ {"contains('', 'a')", "Boolean:false"},
336+ {"contains(null, '')", "Boolean:true"},
337+ {"contains(1234, 23)", "Boolean:true"},
338+ {"contains(true, 'RU')", "Boolean:true"},
339+ {"contains(github, 'a')", "Boolean:false"},
340+ {"contains('a', github)", "Boolean:false"},
341+ {"contains(fromJSON('[]'), null)", "Boolean:false"},
342+ {"contains(fromJSON('[1,\"2\",null]'), null)", "Boolean:true"},
343+ {"contains(fromJSON('[1,\"2\",null]'), '')", "Boolean:true"},
344+ {"contains(fromJSON('[[1]]'), fromJSON('[1]'))", "Boolean:false"},
345+ {"contains('İ', 'i')", "Boolean:true"}, // İ lower-cases to i + U+0307, which contains i
346+ {"contains('ß', 'ss')", "Boolean:false"}, // contains lower-cases: no ß folding
347+ {"contains('A\u03a3', 'a\u03c2')", "Boolean:true"},
348+ {"contains('K', 'k')", "Boolean:true"}, // KELVIN SIGN lower-cases to k
349+ {"contains()", "PARSE Too few parameters supplied: 'contains'"},
350+ {"contains(1)", "PARSE Too few parameters supplied: 'contains'"},
351+ {"contains(1, 2, 3)", "PARSE Too many parameters supplied: 'contains'"},
352+
353+ // startsWith, endsWith
354+ {"startsWith('abc', 'AB')", "Boolean:true"},
355+ {"startsWith('abc', '')", "Boolean:true"},
356+ {"startsWith(null, '')", "Boolean:true"},
357+ {"startsWith(123, 12)", "Boolean:true"},
358+ {"startsWith(github, 'a')", "Boolean:false"},
359+ {"startsWith('a', github)", "Boolean:false"},
360+ {"startsWith('ß', 'ss')", "Boolean:true"}, // upper-cases to SS
361+ {"startsWith('ı', 'I')", "Boolean:false"},
362+ {"endsWith('abc', 'BC')", "Boolean:true"},
363+ {"endsWith('abc', 'abcd')", "Boolean:false"},
364+ {"endsWith(1.50, '5')", "Boolean:true"},
365+ {"endsWith(1.50, 5)", "Boolean:true"},
366+ {"endsWith('abc')", "PARSE Too few parameters supplied: 'endsWith'"},
367+ {"STARTSWITH('abc', 'a')", "Boolean:true"},
368+
369+ // join
370+ {"join('a')", "String:a"},
371+ {"join('a', 'b')", "String:a"},
372+ {"join(null)", "String:"},
373+ {"join(1.50)", "String:1.5"},
374+ {"join(github)", "String:"},
375+ {"join(fromJSON('[]'))", "String:"},
376+ {"join(fromJSON('[1,2,3]'))", "String:1,2,3"},
377+ {"join(fromJSON('[1,2,3]'), '')", "String:123"},
378+ {"join(fromJSON('[1,2,3]'), ', ')", "String:1, 2, 3"},
379+ {"join(fromJSON('[1,2,3]'), 1)", "String:11213"},
380+ {"join(fromJSON('[1,2,3]'), null)", "String:123"},
381+ {"join(fromJSON('[1,2,3]'), github)", "String:1,2,3"}, // a non-primitive separator is ignored
382+ {"join(fromJSON('[null,true,\"a\",1.5,[1],{}]'))", "String:,true,a,1.5,Array,Object"},
383+ {"join()", "PARSE Too few parameters supplied: 'join'"},
384+ {"join(1, 2, 3)", "PARSE Too many parameters supplied: 'join'"},
385+
386+ // toJSON
387+ {"toJSON(null)", "String:null"},
388+ {"toJSON(true)", "String:true"},
389+ {"toJSON(1)", "String:1"},
390+ {"toJSON(NaN)", "String:null"},
391+ {"toJSON(Infinity)", "String:null"},
392+ {"toJSON(-0)", "String:0"},
393+ {"toJSON('x')", "String:\"x\""},
394+ {"toJSON(1e21)", "String:1e+21"},
395+ {"toJSON(1e-7)", "String:1e-7"},
396+ {"toJSON(0.1)", "String:0.1"},
397+ {"toJSON(fromJSON('[]'))", "String:[]"},
398+ {"toJSON(fromJSON('{}'))", "String:{}"},
399+ {"toJSON(fromJSON('[[],{}]'))", "String:[\n [],\n {}\n]"},
400+ {"toJSON(1, 2)", "PARSE Too many parameters supplied: 'toJSON'"},
401+ {"toJSON()", "PARSE Too few parameters supplied: 'toJSON'"},
402+ {"fromJSON(toJSON(github.event.commits[0])).author.name", "String:Ann"},
403+
404+ // fromJSON
405+ {"fromJSON('')", "ERR empty input"},
406+ {"fromJSON(' ')", "ERR empty input"},
407+ {"fromJSON('\n')", "ERR empty input"},
408+ {"fromJSON(null)", "ERR empty input"},
409+ {"fromJSON('{')", "ERR Error parsing JSON when evaluating fromJson"},
410+ {"fromJSON('[1,]')", "ERR Error parsing JSON when evaluating fromJson"},
411+ {"fromJSON('{a:1}')", "ERR Error parsing JSON when evaluating fromJson"},
412+ {"fromJSON('01')", "ERR Error parsing JSON when evaluating fromJson"},
413+ {"fromJSON('NaN')", "ERR Error parsing JSON when evaluating fromJson"},
414+ {"fromJSON('1 2')", "ERR Error parsing JSON when evaluating fromJson"},
415+ {"fromJSON(' 1 ')", "Number:1"},
416+ {"fromJSON('-1.5e2')", "Number:-150"},
417+ {"fromJSON('1e999')", "Number:Infinity"},
418+ {"fromJSON('\"\\u0041\"')", "String:A"},
419+ {"fromJSON('\"\\ud83d\\ude00\"')", "String:😀"},
420+ {"fromJSON(1)", "Number:1"},
421+ {"fromJSON(true)", "Boolean:true"},
422+ {"fromJSON(matrix.node)", "ERR Error parsing JSON when evaluating fromJson"}, // the string "Array"
423+ {"fromJSON(1, 2)", "PARSE Too many parameters supplied: 'fromJSON'"},
424+
425+ // format
426+ {"format('a')", "String:a"},
427+ {"format('')", "String:"},
428+ {"format(null)", "String:"},
429+ {"format(1)", "String:1"},
430+ {"format('{0}', 1)", "String:1"},
431+ {"format('{0}{0}', 'x')", "String:xx"},
432+ {"format('{0}{1}{0}', 'a', 'b')", "String:aba"},
433+ {"format('{{')", "String:{"},
434+ {"format('}}')", "String:}"},
435+ {"format('{{}}')", "String:{}"},
436+ {"format('{{0}}', 1)", "String:{0}"},
437+ {"format('{{{0}}}', 1)", "String:{1}"},
438+ {"format('{0}', 'a', 'b')", "String:a"}, // extra arguments are fine
439+ {"format('{0:x}', 1)", "String:1"}, // a format specifier is skipped
440+ {"format('{0 anything}', 1)", "String:1"},
441+ {"format('{00}', 1)", "String:1"},
442+ {"format('{01}', 1, 2)", "String:2"},
443+ {"format('{0}', null)", "String:"},
444+ {"format('{0}', true)", "String:true"},
445+ {"format('{0}', 0.1)", "String:0.1"},
446+ {"format('{0}', github)", "String:Object"},
447+ {"format('{0}', matrix.node)", "String:Array"},
448+ {"format('{0}-{1}', 1, 'a')", "String:1-a"},
449+ {"format('x{0}y', '😀')", "String:x😀y"},
450+ {"format('{1}', 1)", "ERR The following format string references more arguments than were supplied: {1}"},
451+ {"format('{0}')", "ERR The following format string references more arguments than were supplied: {0}"},
452+ {"format('{', 1)", "ERR The following format string is invalid: {"},
453+ {"format('}', 1)", "ERR The following format string is invalid: }"},
454+ {"format('{}', 1)", "ERR The following format string is invalid: {}"},
455+ {"format('{ 0}', 1)", "ERR The following format string is invalid: { 0}"},
456+ {"format('{-1}', 1)", "ERR The following format string is invalid: {-1}"},
457+ {"format('{a}', 1)", "ERR The following format string is invalid: {a}"},
458+ {"format('{0', 1)", "ERR The following format string is invalid: {0"},
459+ {"format('0}', 1)", "ERR The following format string is invalid: 0}"},
460+ {"format('{{0}', 1)", "ERR The following format string is invalid: {{0}"},
461+ {"format('{0}}', 1)", "ERR The following format string is invalid: {0}}"},
462+ {"format('{12345678901234567890123}', 1)", "ERR The following format string references more arguments than were supplied: {12345678901234567890123}"},
463+ {"format()", "PARSE Too few parameters supplied: 'format'"},
464+
465+ // case
466+ {"case(true, 'a', 'b')", "String:a"},
467+ {"case(false, 'a', 'b')", "String:b"},
468+ {"case(false, 1, true, 2, 3)", "Number:2"},
469+ {"case(false, 1, false, 2, 3)", "Number:3"},
470+ {"case(true, 1, true, 2, 3)", "Number:1"},
471+ {"case(github.event_name == 'push', 'a', github.event_name == 'pr', 'b', 'c')", "String:a"},
472+ {"CASE(true, matrix.node, 2)", "Array:Array"},
473+ {"case(1, 2, 3)", "ERR case predicate must evaluate to a boolean value"},
474+ {"case('true', 2, 3)", "ERR case predicate must evaluate to a boolean value"},
475+ {"case(null, 2, 3)", "ERR case predicate must evaluate to a boolean value"},
476+ {"case(true, 1, 2, 3)", "PARSE Even number of parameters supplied, requires an odd number of parameters: 'case'"},
477+ {"case(true, 1)", "PARSE Too few parameters supplied: 'case'"},
478+ {"case()", "PARSE Too few parameters supplied: 'case'"},
479+ // Predicates after the first true one are not checked...
480+ {"case(true, 1, 'bad', 2, 3)", "Number:1"},
481+ // ...but earlier ones are.
482+ {"case('bad', 1, true, 2, 3)", "ERR case predicate must evaluate to a boolean value"},
483+ // The default is returned as is, whatever it is.
484+ {"case(false, 1, null)", "Null:"},
485+ })
486+}
487+
488+func TestEvalErrorMessages(t *testing.T) {
489+ runEvalCases(t, []evalCase{
490+ {"", "PARSE Unexpected end of expression: 'x'"}, // replaced below: the empty expression is not an error
491+ }[:0])
492+ cases := []evalCase{
493+ {"1 +", "PARSE Unexpected symbol: '+'. Located at position 3 within expression: 1 +"},
494+ {"a b", "PARSE Unrecognized named-value: 'a'"},
495+ {"github github", "PARSE Unexpected symbol: 'github'"},
496+ {"github.event_name ==", "PARSE Unexpected end of expression: '=='"},
497+ {"== 1", "PARSE Unexpected symbol: '=='"},
498+ {"(github.event_name", "PARSE Unexpected end of expression: 'event_name'"},
499+ {"github.event_name)", "PARSE Unexpected symbol: ')'"},
500+ {"contains('a',", "PARSE Unexpected end of expression: ','"},
501+ {"contains('a'", "PARSE Unexpected symbol: 'EOF'"},
502+ {"contains('a' 'b')", "PARSE Unexpected symbol: 'b'"},
503+ {"foo(", "PARSE Unexpected end of expression: '('"},
504+ {"foo()", "PARSE Unrecognized function: 'foo'"},
505+ {"foo", "PARSE Unrecognized named-value: 'foo'"},
506+ {"unknown(1)", "PARSE Unrecognized function: 'unknown'"},
507+ {"success()", "PARSE Unrecognized function: 'success'"}, // not declared in these tests
508+ {"(", "PARSE Unexpected end of expression: '('"},
509+ {")", "PARSE Unexpected symbol: ')'"},
510+ {"()", "PARSE Unexpected symbol: ')'"},
511+ {"!", "PARSE Unexpected end of expression: '!'"},
512+ {"1 !", "PARSE Unexpected symbol: '!'"},
513+ {"[1]", "PARSE Unexpected symbol: '['"},
514+ {"github.[0]", "PARSE Unexpected symbol: '['"},
515+ {"github.", "PARSE Unexpected symbol: 'EOF'"},
516+ {"github[", "PARSE Unexpected end of expression: '['"},
517+ {"github[]", "PARSE Unexpected symbol: ']'"},
518+ {"github[*", "PARSE Unexpected symbol: 'EOF'"},
519+ {"github.true", "Null:"}, // keywords are plain identifiers after a dot
520+ {"env.true", "Null:"},
521+ {"1 = 1", "PARSE Unexpected symbol: '='. Located at position 3 within expression: 1 = 1"},
522+ {"1 & 1", "PARSE Unexpected symbol: '&'. Located at position 3 within expression: 1 & 1"},
523+ {"1 | 1", "PARSE Unexpected symbol: '|'. Located at position 3 within expression: 1 | 1"},
524+ {"1 + 1", "PARSE Unexpected symbol: '+'. Located at position 3 within expression: 1 + 1"},
525+ {"a-b", "PARSE Unrecognized named-value: 'a-b'"},
526+ {"1a", "PARSE Unexpected symbol: '1a'. Located at position 1 within expression: 1a"},
527+ {"'unterminated", "PARSE Unexpected symbol: ''unterminated'. Located at position 1 within expression: 'unterminated"},
528+ {"@", "PARSE Unexpected symbol: '@'. Located at position 1 within expression: @"},
529+ {"${{ 1 }}", "PARSE Unexpected symbol: '${{'. Located at position 1 within expression: ${{ 1 }}"},
530+ {"1 \u00a0 2", "PARSE Unexpected symbol: '\u00a0'. Located at position 3 within expression: 1 \u00a0 2"},
531+ }
532+ runEvalCases(t, cases)
533+}
534+
535+func TestEmptyExpression(t *testing.T) {
536+ for _, s := range []string{"", " ", "\n", " \t\r\n "} {
537+ node, err := Parse(s, nil, nil)
538+ if err != nil || node != nil {
539+ t.Errorf("Parse(%q) = %v, %v; want nil, nil", s, node, err)
540+ }
541+ }
542+ if _, err := Evaluate(nil, EvalContext{}); err == nil {
543+ t.Error("Evaluate(nil) succeeded")
544+ }
545+}
546+
547+func TestParseErrorPositions(t *testing.T) {
548+ tests := []struct {
549+ src string
550+ line, col int
551+ msg string
552+ }{
553+ {"1 ==\n 2 ===\n 3", 1, 5, "Unexpected symbol: '='. Located at position 11 within expression: 1 ==\n 2 ===\n 3"},
554+ {"true &&\n false ||\n nope", 2, 2, "Unrecognized named-value: 'nope'"},
555+ {"\n\n bogus", 2, 2, "Unrecognized named-value: 'bogus'"},
556+ {"github github", 0, 7, "Unexpected symbol: 'github'"},
557+ {" ) ", 0, 2, "Unexpected symbol: ')'"},
558+ {"contains('a',", 0, 12, "Unexpected end of expression: ','"},
559+ {"foo()", 0, 0, "Unrecognized function: 'foo'"},
560+ {"1 && foo()", 0, 5, "Unrecognized function: 'foo'"},
561+ {"'a'\n 'b'\n zzz", 1, 1, "Unexpected symbol: 'b'"},
562+ }
563+ for _, tc := range tests {
564+ _, err := Parse(tc.src, testContextNames, nil)
565+ var pe *Error
566+ if !errors.As(err, &pe) {
567+ t.Errorf("Parse(%q): error %v is not *Error", tc.src, err)
568+ continue
569+ }
570+ if pe.Message != tc.msg || pe.Pos.Line != tc.line || pe.Pos.Column != tc.col {
571+ t.Errorf("Parse(%q) = %q at %v; want %q at %d:%d", tc.src, pe.Message, pe.Pos, tc.msg, tc.line, tc.col)
572+ }
573+ }
574+}
575+
576+func TestParseErrorTypes(t *testing.T) {
577+ tests := []struct {
578+ src string
579+ want ErrorType
580+ }{
581+ {"1 ==", ErrorUnexpectedEndOfExpression},
582+ {")", ErrorUnexpectedSymbol},
583+ {"nope", ErrorUnrecognizedContext},
584+ {"nope()", ErrorUnrecognizedFunction},
585+ {"contains(1)", ErrorTooFewParameters},
586+ {"contains(1,2,3)", ErrorTooManyParameters},
587+ {"case(true, 1, 2, 3)", ErrorEvenParameters},
588+ {strings.Repeat("(", 60) + "1" + strings.Repeat(")", 60), ErrorExceededMaxDepth},
589+ {"1 = 1", ErrorUnexpectedSymbol},
590+ }
591+ for _, tc := range tests {
592+ _, err := Parse(tc.src, nil, nil)
593+ var pe *Error
594+ if !errors.As(err, &pe) || pe.Type != tc.want {
595+ t.Errorf("Parse(%.30q): got %#v, want type %d", tc.src, err, tc.want)
596+ }
597+ }
598+}
599+
600+func TestLimits(t *testing.T) {
601+ deepParens := func(n int) string { return strings.Repeat("(", n) + "1" + strings.Repeat(")", n) }
602+ tests := []struct {
603+ name string
604+ src string
605+ ok bool
606+ }{
607+ // Each "(" costs one level, plus one for the outermost expression.
608+ {"49 parens", deepParens(49), true},
609+ {"50 parens", deepParens(50), false},
610+ {"49 nots", strings.Repeat("!", 49) + "true", true},
611+ {"50 nots", strings.Repeat("!", 50) + "true", false},
612+ {"49 calls", strings.Repeat("format(", 49) + "'x'" + strings.Repeat(")", 49), true},
613+ {"50 calls", strings.Repeat("format(", 50) + "'x'" + strings.Repeat(")", 50), false},
614+ {"long dotted path", "github" + strings.Repeat(".event", 49), true},
615+ {"too long dotted path", "github" + strings.Repeat(".event", 50), false},
616+ {"long flat ||", "1" + strings.Repeat(" || 1", 3000), true},
617+ {"long flat ==", "1" + strings.Repeat(" == 1", 3000), true},
618+ }
619+ for _, tc := range tests {
620+ _, err := Parse(tc.src, testContextNames, nil)
621+ if (err == nil) != tc.ok {
622+ t.Errorf("%s: err = %v, want ok=%v", tc.name, err, tc.ok)
623+ }
624+ var pe *Error
625+ if err != nil && errors.As(err, &pe) && pe.Type == ErrorExceededMaxDepth && pe.Message != "Exceeded max expression depth 50: '1'" && !strings.HasPrefix(pe.Message, "Exceeded max expression depth 50: '") {
626+ t.Errorf("%s: message %q", tc.name, pe.Message)
627+ }
628+ }
629+
630+ // Length: counted in UTF-16 code units, error is the bare token.
631+ long := "'" + strings.Repeat("a", MaxExpressionLength-2) + "'"
632+ if _, err := Parse(long, nil, nil); err != nil {
633+ t.Errorf("expression of exactly %d characters: %v", MaxExpressionLength, err)
634+ }
635+ _, err := Parse(long+" ", nil, nil)
636+ if err == nil || err.Error() != "ErrorExceededMaxLength" {
637+ t.Errorf("expression of %d characters: err = %v", MaxExpressionLength+1, err)
638+ }
639+ // 10501 emoji are 21002 UTF-16 units but only 10501 runes.
640+ emoji := "'" + strings.Repeat("😀", 10500) + "'"
641+ if _, err := Parse(emoji, nil, nil); err == nil {
642+ t.Errorf("a string of 10500 emoji (21002 UTF-16 units) was accepted")
643+ }
644+ emoji = "'" + strings.Repeat("😀", 10499) + "'"
645+ if _, err := Parse(emoji, nil, nil); err != nil {
646+ t.Errorf("a string of 10499 emoji (21000 UTF-16 units) was rejected: %v", err)
647+ }
648+}
649+
650+func TestDepthMessage(t *testing.T) {
651+ _, err := Parse(strings.Repeat("(", 60)+"1"+strings.Repeat(")", 60), nil, nil)
652+ want := "Exceeded max expression depth 50: '('"
653+ if err == nil || err.Error() != want {
654+ t.Errorf("got %v, want %q", err, want)
655+ }
656+}
657+
658+func TestExtensionFunctions(t *testing.T) {
659+ funcs := []FuncInfo{
660+ {Name: "hashFiles", MinArgs: 1, MaxArgs: -1},
661+ {Name: "success", MinArgs: 0, MaxArgs: 0},
662+ {Name: "bounded", MinArgs: 1, MaxArgs: 2},
663+ {Name: "contains", MinArgs: 0, MaxArgs: 0}, // built-ins win for validation
664+ }
665+ for _, c := range []struct{ src, err string }{
666+ {"hashFiles('a')", ""},
667+ {"HASHFILES('a', 'b', 'c', 'd')", ""},
668+ {"hashFiles()", "Too few parameters supplied: 'hashFiles'"},
669+ {"success()", ""},
670+ {"success(1)", "Too many parameters supplied: 'success'"},
671+ {"bounded(1, 2)", ""},
672+ {"bounded(1, 2, 3)", "Too many parameters supplied: 'bounded'"},
673+ {"contains()", "Too few parameters supplied: 'contains'"},
674+ {"nothere()", "Unrecognized function: 'nothere'"},
675+ } {
676+ _, err := Parse(c.src, nil, funcs)
677+ switch {
678+ case c.err == "" && err != nil:
679+ t.Errorf("%s: %v", c.src, err)
680+ case c.err != "" && (err == nil || err.Error() != c.err):
681+ t.Errorf("%s: err = %v, want %q", c.src, err, c.err)
682+ }
683+ }
684+
685+ calls := 0
686+ ctx := EvalContext{
687+ Contexts: map[string]Value{"G": String("v")},
688+ Funcs: map[string]Func{
689+ "HashFiles": func(args []Value) (Value, error) {
690+ calls++
691+ return String("h:" + CoerceString(args[0])), nil
692+ },
693+ "success": func([]Value) (Value, error) { return Boolean(true), nil },
694+ "boom": func([]Value) (Value, error) { return nil, errors.New("kaput") },
695+ // An extra function replaces a built-in of the same name.
696+ "contains": func([]Value) (Value, error) { return String("mine"), nil },
697+ },
698+ }
699+ node, err := Parse("hashFiles(g) == 'h:v' && success() && contains('a','b')", []string{"g"}, funcs)
700+ if err != nil {
701+ t.Fatal(err)
702+ }
703+ v, err := Evaluate(node, ctx)
704+ if err != nil || CoerceString(v) != "mine" || calls != 1 {
705+ t.Errorf("got %v, %v, %d calls", v, err, calls)
706+ }
707+ node, err = Parse("boom()", nil, []FuncInfo{{Name: "boom"}})
708+ if err != nil {
709+ t.Fatal(err)
710+ }
711+ if _, err = Evaluate(node, ctx); err == nil || err.Error() != "kaput" {
712+ t.Errorf("function error not propagated: %v", err)
713+ }
714+ // A declared function without an implementation is an evaluation error.
715+ node, _ = Parse("success()", nil, funcs)
716+ if _, err = Evaluate(node, EvalContext{}); err == nil {
717+ t.Error("calling an unimplemented function succeeded")
718+ }
719+}
720+
721+func TestContextNames(t *testing.T) {
722+ node, err := Parse("GitHub.Ref", []string{"github"}, nil)
723+ if err != nil {
724+ t.Fatal(err)
725+ }
726+ for _, key := range []string{"github", "GITHUB", "GitHub"} {
727+ d := NewDictionary()
728+ d.Add("ref", String("main"))
729+ v, err := Evaluate(node, EvalContext{Contexts: map[string]Value{key: d}})
730+ if err != nil || CoerceString(v) != "main" {
731+ t.Errorf("context key %q: %v, %v", key, v, err)
732+ }
733+ }
734+ // A declared but absent context is null.
735+ v, err := Evaluate(node, EvalContext{})
736+ if err != nil || v.Kind() != KindNull {
737+ t.Errorf("absent context: %v, %v", v, err)
738+ }
739+ // Names that were not declared to Parse are errors, even if present at evaluation.
740+ if _, err := Parse("secrets.X", []string{"github"}, nil); err == nil || err.Error() != "Unrecognized named-value: 'secrets'" {
741+ t.Errorf("undeclared context: %v", err)
742+ }
743+ // A nil Value in a context behaves as null.
744+ node, _ = Parse("github.a.b", []string{"github"}, nil)
745+ if v, err := Evaluate(node, EvalContext{Contexts: map[string]Value{"github": nil}}); err != nil || v.Kind() != KindNull {
746+ t.Errorf("nil context value: %v, %v", v, err)
747+ }
748+ var nilDict *Dictionary
749+ var nilArr *Array
750+ node, _ = Parse("(github)[0] || x", []string{"github", "x"}, nil)
751+ for _, bad := range []Value{nilDict, nilArr} {
752+ if _, err := Evaluate(node, EvalContext{Contexts: map[string]Value{"github": bad}}); err != nil {
753+ t.Errorf("typed nil %T: %v", bad, err)
754+ }
755+ }
756+}
757+
758+func TestEvaluateDoesNotMutateContext(t *testing.T) {
759+ ctxs := testContexts(t)
760+ before := map[string]string{}
761+ for k, v := range ctxs {
762+ before[k] = ToJSON(v)
763+ }
764+ for _, src := range []string{"github.event.commits.*", "github.event.commits.*.added.*", "github.*", "matrix.node.*", "toJSON(steps.*)"} {
765+ node, err := Parse(src, testContextNames, nil)
766+ if err != nil {
767+ t.Fatal(err)
768+ }
769+ v, err := Evaluate(node, EvalContext{Contexts: ctxs})
770+ if err != nil {
771+ t.Fatal(err)
772+ }
773+ if a, ok := v.(*Array); ok {
774+ a.Append(String("tampered"))
775+ }
776+ }
777+ for k, v := range ctxs {
778+ if ToJSON(v) != before[k] {
779+ t.Errorf("context %s was modified", k)
780+ }
781+ }
782+}
783+
784+func TestFloatCoercion(t *testing.T) {
785+ tests := []struct {
786+ in float64
787+ want string
788+ }{
789+ {0, "0"},
790+ {math.Copysign(0, -1), "0"},
791+ {1, "1"},
792+ {-1, "-1"},
793+ {1.5, "1.5"},
794+ {0.1, "0.1"},
795+ {0.30000000000000004, "0.3"},
796+ {1.005, "1.005"},
797+ {100, "100"},
798+ {1e20, "100000000000000000000"},
799+ {1e21, "1e+21"},
800+ {123456789012345680000, "123456789012345680000"},
801+ {1e-7, "1e-7"}, // toFixed(15) keeps 0.000000100000000, which prints as 1e-7
802+ {0.000001, "0.000001"},
803+ {5e-16, "1e-15"}, // toFixed rounds half up on the exact binary value
804+ {1e-15, "1e-15"},
805+ {1e-16, "0"},
806+ {-1e-16, "0"},
807+ {12345678901234567890, "12345678901234567000"},
808+ {math.NaN(), "NaN"},
809+ {math.Inf(1), "Infinity"},
810+ {math.Inf(-1), "-Infinity"},
811+ {2.5, "2.5"},
812+ {-0.25, "-0.25"},
813+ {4503599627370497.5, "4503599627370498"}, // the literal itself rounds to the nearest double
814+ {1.7976931348623157e308, "1.7976931348623157e+308"},
815+ {5e-324, "0"},
816+ }
817+ for _, tc := range tests {
818+ if got := CoerceString(Number(tc.in)); got != tc.want {
819+ t.Errorf("CoerceString(%v) = %q, want %q", tc.in, got, tc.want)
820+ }
821+ }
822+}
823+
824+func TestJSNumberToString(t *testing.T) {
825+ one, two := 0.1, 0.2
826+ sum := one + two // not a constant expression: Go would fold 0.1+0.2 exactly
827+ tests := []struct {
828+ in float64
829+ want string
830+ }{
831+ {0, "0"}, {1, "1"}, {-1.5, "-1.5"}, {100, "100"}, {1e20, "100000000000000000000"}, {1e21, "1e+21"},
832+ {1.5e21, "1.5e+21"}, {1e-6, "0.000001"}, {1e-7, "1e-7"}, {1.5e-7, "1.5e-7"}, {123456.789, "123456.789"},
833+ {0.000123, "0.000123"}, {5e-324, "5e-324"}, {1.7976931348623157e308, "1.7976931348623157e+308"},
834+ {-1e-7, "-1e-7"}, {sum, "0.30000000000000004"}, {math.NaN(), "NaN"}, {math.Inf(-1), "-Infinity"},
835+ }
836+ for _, tc := range tests {
837+ if got := jsNumberToString(tc.in); got != tc.want {
838+ t.Errorf("jsNumberToString(%v) = %q, want %q", tc.in, got, tc.want)
839+ }
840+ }
841+}
842+
843+func TestJSToNumber(t *testing.T) {
844+ nan := math.NaN()
845+ tests := []struct {
846+ in string
847+ want float64
848+ }{
849+ {"", 0}, {" ", 0}, {"\n\t", 0}, {"1", 1}, {" 1 ", 1}, {"\u00a01\u2003", 1}, {"\ufeff1", 1}, {"+1", 1}, {"-1", -1},
850+ {"1.", 1}, {".5", 0.5}, {"-.5", -0.5}, {"1e3", 1000}, {"1E-3", 0.001}, {"1e+3", 1000}, {"0x10", 16}, {"0X1f", 31},
851+ {"0o17", 15}, {"0b101", 5}, {"0x", nan}, {"0xg", nan}, {"-0x10", nan}, {"+0x10", nan}, {"0b2", nan}, {"1_000", nan},
852+ {"Infinity", math.Inf(1)}, {"-Infinity", math.Inf(-1)}, {"+Infinity", math.Inf(1)}, {"infinity", nan}, {"inf", nan},
853+ {"NaN", nan}, {"nan", nan}, {"abc", nan}, {"1e", nan}, {"1e+", nan}, {".", nan}, {"e5", nan}, {"1 2", nan}, {"1,5", nan},
854+ {"00", 0}, {"007", 7}, {"0.0", 0}, {"1e999", math.Inf(1)}, {"-1e999", math.Inf(-1)}, {"0x1fffffffffffff", 9007199254740991},
855+ {"12345678901234567890", 12345678901234567890}, {"٣", nan}, {"--1", nan}, {"+-1", nan}, {"1e1.5", nan}, {"0x1.8", nan},
856+ }
857+ for _, tc := range tests {
858+ got := jsToNumber(tc.in)
859+ if got != tc.want && !(math.IsNaN(got) && math.IsNaN(tc.want)) {
860+ t.Errorf("jsToNumber(%q) = %v, want %v", tc.in, got, tc.want)
861+ }
862+ }
863+}
864+
865+func TestJSCasing(t *testing.T) {
866+ upper := map[string]string{"abc": "ABC", "ß": "SS", "ʼn": "ʼN", "dž": "DŽ", "ı": "I", "ffi": "FFI", "ὒ": "Υ̓̀", "ᾀ": "ἈΙ", "σ": "Σ", "ς": "Σ", "😀": "😀", "é": "É"}
867+ for in, want := range upper {
868+ if got := jsToUpper(in); got != want {
869+ t.Errorf("jsToUpper(%q) = %q, want %q", in, got, want)
870+ }
871+ }
872+ special := map[string]string{"ı": "ı", "ıi": "ıI", "I": "I", "ß": "SS", "aıb": "AıB"}
873+ for in, want := range special {
874+ if got := toUpperSpecial(in); got != want {
875+ t.Errorf("toUpperSpecial(%q) = %q, want %q", in, got, want)
876+ }
877+ }
878+ lower := map[string]string{
879+ "ABC": "abc", "İ": "i\u0307", "ΑΣ": "ας", "ΑΣΑ": "ασα", "Α Σ": "α σ", "ΑΣ.": "ας.", "Σ": "σ", "ΑΣ'Α": "ασ'α",
880+ "K": "k", "Å": "å", "😀": "😀", "DŽ": "dž", "ΑΣΣ": "ασς", "Α.Σ": "α.ς",
881+ }
882+ for in, want := range lower {
883+ if got := jsToLower(in); got != want {
884+ t.Errorf("jsToLower(%q) = %q, want %q", in, got, want)
885+ }
886+ }
887+}
888+
889+func TestCompareUTF16(t *testing.T) {
890+ // U+10000 is the surrogate pair D800 DC00, which sorts before U+E000.
891+ if got := compareUTF16("\U00010000", "\ue000"); got >= 0 {
892+ t.Errorf("compareUTF16(U+10000, U+E000) = %d, want < 0", got)
893+ }
894+ if got := compareUTF16("\uffff", "\U00010000"); got <= 0 {
895+ t.Errorf("compareUTF16(U+FFFF, U+10000) = %d, want > 0", got)
896+ }
897+ for _, c := range []struct {
898+ a, b string
899+ want int
900+ }{{"a", "a", 0}, {"a", "b", -1}, {"b", "a", 1}, {"a", "ab", -1}, {"ab", "a", 1}, {"", "", 0}, {"", "a", -1}} {
901+ if got := compareUTF16(c.a, c.b); got != c.want {
902+ t.Errorf("compareUTF16(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
903+ }
904+ }
905+ // And through the operators.
906+ for _, c := range evalCasesUTF16 {
907+ if got := evalString(t, c.expr); got != c.want {
908+ t.Errorf("%s = %s, want %s", c.expr, got, c.want)
909+ }
910+ }
911+}
912+
913+var evalCasesUTF16 = []evalCase{
914+ {"'\U00010000' < '\ue000'", "Boolean:true"},
915+ {"'\ue000' > '\U00010000'", "Boolean:true"},
916+ {"'\U00010000' >= '\ue000'", "Boolean:false"},
917+}
918+
919+func TestTokenString(t *testing.T) {
920+ toks, err := Lex("a.b == 'x''y' && 1.5")
921+ if err != nil {
922+ t.Fatal(err)
923+ }
924+ var got []string
925+ for _, tk := range toks {
926+ got = append(got, tk.Type.String())
927+ }
928+ want := "IDENTIFIER DOT IDENTIFIER EQUAL_EQUAL STRING AND NUMBER EOF"
929+ if strings.Join(got, " ") != want {
930+ t.Errorf("tokens = %v, want %s", got, want)
931+ }
932+ if toks[4].Value.(String) != "x'y" || toks[4].Lexeme != "'x''y'" {
933+ t.Errorf("string token = %#v", toks[4])
934+ }
935+ if TokenType(99).String() != "TokenType(99)" {
936+ t.Error("unknown token type")
937+ }
938+}
939+
940+// The EOF token of the reference is positioned from a stale token start, so
941+// after a trailing newline its column is negative. Kept as is.
942+func TestEOFTokenPosition(t *testing.T) {
943+ toks, err := Lex("a\n")
944+ if err != nil {
945+ t.Fatal(err)
946+ }
947+ eof := toks[len(toks)-1]
948+ if eof.Type != TokenEOF || eof.Range.Start != (Position{Line: 1, Column: -1}) || eof.Range.End != (Position{Line: 1, Column: 0}) {
949+ t.Errorf("EOF token = %+v", eof)
950+ }
951+}
A expr/fuzz_test.go +190 -0
@@ -0,0 +1,190 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
6+ "strings"
7+ "testing"
8+ "unicode/utf8"
9+)
10+
11+// Seeds for the fuzz targets: the corpus of the table tests and a few shapes
12+// that stress the lexer and the parser.
13+var fuzzSeeds = []string{
14+ "", " ", "true", "github.event_name == 'push'", "1 == '1'", "'it''s'", "'unterminated", "a-b", "NaN", "-Infinity", "0x1F", "1e3", ".5", "5.",
15+ "github.event.commits.*.id", "github['x'][0].*", "contains(github.ref, 'main') && !failure()", "format('{0} {{}} {1}', 1, null)",
16+ "fromJSON('[1,{\"a\":null}]')[1].a", "toJSON(fromJSON('{\"b\":1,\"1\":2}'))", "join(matrix.node.*, ', ')", "case(true, 1, 2)", "case(false, 1, true, 2, 3)",
17+ "((((((((((1))))))))))", "!!!!!!true", "1 || 2 && 3 == 4 < 5", "hashFiles('**/*.go', '!vendor/**')", "success() && always()", "\n\t \r", "\u00a0", "'😀' == '\U00010000'",
18+ "=", "&", "|", "!=", "<=", ">=", "()", "(1", "1)", "a.", ".a", "a..b", "a[", "a[]", "a[*]", "*", "a.*.*", "1.2.3", "--1", "1_000", "'\xed\xa0\x80'",
19+ "format('{0:x}{', 1)", "format('}}{{', 1)", "contains(1,2,3)", "ß == 'SS'", "İ", "'ı' == 'I'", "'\u03a3' < 'z'",
20+}
21+
22+func addSeeds(f *testing.F) {
23+ for _, s := range fuzzSeeds {
24+ f.Add(s)
25+ }
26+}
27+
28+// FuzzLex checks that lexing never panics, that it is deterministic, and
29+// that successful lexing yields ordered tokens ending with exactly one EOF.
30+func FuzzLex(f *testing.F) {
31+ addSeeds(f)
32+ f.Fuzz(func(t *testing.T, s string) {
33+ toks, err := Lex(s)
34+ toks2, err2 := Lex(s)
35+ if (err == nil) != (err2 == nil) || len(toks) != len(toks2) {
36+ t.Fatal("lexing is not deterministic")
37+ }
38+ if err != nil {
39+ if _, ok := err.(*Error); !ok {
40+ t.Fatalf("error is %T, not *Error", err)
41+ }
42+ return
43+ }
44+ if len(toks) == 0 || toks[len(toks)-1].Type != TokenEOF {
45+ t.Fatal("token stream does not end with EOF")
46+ }
47+ for i, tk := range toks {
48+ if tk.Type == TokenEOF && i != len(toks)-1 {
49+ t.Fatal("EOF in the middle of the token stream")
50+ }
51+ if tk.Type != TokenEOF && tk.Lexeme == "" {
52+ t.Fatalf("empty lexeme for token %v", tk.Type)
53+ }
54+ if !utf8.ValidString(tk.Lexeme) && utf8.ValidString(s) {
55+ t.Fatalf("invalid UTF-8 lexeme %q from valid input", tk.Lexeme)
56+ }
57+ // The EOF token of the reference starts at a stale offset, so after
58+ // a trailing newline its start column is -1; every other token
59+ // has a column of at least 0.
60+ if tk.Range.Start.Line < 0 || (tk.Type != TokenEOF && tk.Range.Start.Column < 0) || tk.Range.End.Line < tk.Range.Start.Line {
61+ t.Fatalf("bad range %+v", tk.Range)
62+ }
63+ }
64+ })
65+}
66+
67+// FuzzParse checks that parsing never panics, that errors are *Error and
68+// that the depth limit holds: no accepted expression nests deeper than the
69+// parser allows.
70+func FuzzParse(f *testing.F) {
71+ addSeeds(f)
72+ f.Fuzz(func(t *testing.T, s string) {
73+ node, err := Parse(s, []string{"github", "matrix", "env"}, []FuncInfo{{Name: "hashFiles", MinArgs: 1, MaxArgs: -1}, {Name: "success"}})
74+ if err != nil {
75+ if _, ok := err.(*Error); !ok {
76+ t.Fatalf("error is %T, not *Error", err)
77+ }
78+ if node != nil {
79+ t.Fatal("node returned with an error")
80+ }
81+ return
82+ }
83+ if node == nil && strings.TrimFunc(s, func(r rune) bool { return r == ' ' || r == '\t' || r == '\r' || r == '\n' }) != "" {
84+ t.Fatalf("empty tree for non-blank input %q", s)
85+ }
86+ if d := nodeDepth(node); d > 2*MaxParserDepth+2 {
87+ t.Fatalf("tree depth %d exceeds the parser limit", d)
88+ }
89+ })
90+}
91+
92+func nodeDepth(n Node) int {
93+ switch n := n.(type) {
94+ case *Unary:
95+ return 1 + nodeDepth(n.Expr)
96+ case *Binary:
97+ return 1 + max(nodeDepth(n.Left), nodeDepth(n.Right))
98+ case *Logical:
99+ d := 0
100+ for _, a := range n.Args {
101+ d = max(d, nodeDepth(a))
102+ }
103+ return 1 + d
104+ case *Grouping:
105+ return 1 + nodeDepth(n.Group)
106+ case *IndexAccess:
107+ return 1 + max(nodeDepth(n.Expr), nodeDepth(n.Index))
108+ case *FunctionCall:
109+ d := 0
110+ for _, a := range n.Args {
111+ d = max(d, nodeDepth(a))
112+ }
113+ return 1 + d
114+ }
115+ return 1
116+}
117+
118+// FuzzEvaluate checks that evaluating an arbitrary parsed expression never
119+// panics, always returns a usable value or an *EvalError, and leaves the
120+// context untouched.
121+func FuzzEvaluate(f *testing.F) {
122+ addSeeds(f)
123+ ctx := EvalContext{
124+ Contexts: map[string]Value{
125+ "github": mustJSON(f, `{"event_name":"push","ref":"refs/heads/main","event":{"commits":[{"id":"1","added":["a"]},{"id":"2"},{"x":[[1],[2,3]]}],"n":12,"nested":[[1,2],[3]]}}`),
126+ "matrix": mustJSON(f, `{"node":[14,16],"os":"linux","o":{"k":null}}`),
127+ "env": mustJSON(f, `{"A":"","B":"1","C":"x","J":"{\"a\":[1,2]}"}`),
128+ },
129+ Funcs: map[string]Func{
130+ "hashfiles": func(args []Value) (Value, error) { return String("h"), nil },
131+ "success": func([]Value) (Value, error) { return Boolean(true), nil },
132+ },
133+ }
134+ before := map[string]string{}
135+ for k, v := range ctx.Contexts {
136+ before[k] = ToJSON(v)
137+ }
138+ f.Fuzz(func(t *testing.T, s string) {
139+ node, err := Parse(s, []string{"github", "matrix", "env"}, []FuncInfo{{Name: "hashFiles", MinArgs: 1, MaxArgs: -1}, {Name: "success"}})
140+ if err != nil || node == nil {
141+ return
142+ }
143+ v, err := Evaluate(node, ctx)
144+ if err != nil {
145+ if _, ok := err.(*EvalError); !ok {
146+ t.Fatalf("error is %T, not *EvalError", err)
147+ }
148+ return
149+ }
150+ if v == nil {
151+ t.Fatal("nil value without error")
152+ }
153+ _ = CoerceString(v)
154+ _ = Truthy(v)
155+ j := ToJSON(v)
156+ if !utf8.ValidString(j) {
157+ t.Fatalf("toJSON produced invalid UTF-8: %q", j)
158+ }
159+ if _, err := FromJSON([]byte(j)); err != nil {
160+ t.Fatalf("toJSON output does not parse: %v\n%s", err, j)
161+ }
162+ for k, c := range ctx.Contexts {
163+ if ToJSON(c) != before[k] {
164+ t.Fatalf("context %s was modified", k)
165+ }
166+ }
167+ })
168+}
169+
170+// FuzzFromJSON checks that FromJSON never panics and that whatever it
171+// accepts prints as JSON that parses back to the same text.
172+func FuzzFromJSON(f *testing.F) {
173+ for _, s := range []string{`null`, `{"a":[1,2,{"b":null}]}`, `"\ud83d\ude00"`, `{"1":1,"a":2,"01":3}`, `[1e999]`, `-0`, `"\ud800"`, `{"a":1,"A":2}`, `[`, `{"a"`, "\"\x01\""} {
174+ f.Add(s)
175+ }
176+ f.Fuzz(func(t *testing.T, s string) {
177+ v, err := FromJSON([]byte(s))
178+ if err != nil {
179+ return
180+ }
181+ j := ToJSON(v)
182+ v2, err := FromJSON([]byte(j))
183+ if err != nil {
184+ t.Fatalf("round trip failed: %v\n%s", err, j)
185+ }
186+ if j2 := ToJSON(v2); j2 != j {
187+ t.Fatalf("round trip changed output:\n%s\n%s", j, j2)
188+ }
189+ })
190+}
@@ -0,0 +1,190 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
6+ "strings"
7+ "testing"
8+ "unicode/utf8"
9+)
10+
11+// Seeds for the fuzz targets: the corpus of the table tests and a few shapes
12+// that stress the lexer and the parser.
13+var fuzzSeeds = []string{
14+ "", " ", "true", "github.event_name == 'push'", "1 == '1'", "'it''s'", "'unterminated", "a-b", "NaN", "-Infinity", "0x1F", "1e3", ".5", "5.",
15+ "github.event.commits.*.id", "github['x'][0].*", "contains(github.ref, 'main') && !failure()", "format('{0} {{}} {1}', 1, null)",
16+ "fromJSON('[1,{\"a\":null}]')[1].a", "toJSON(fromJSON('{\"b\":1,\"1\":2}'))", "join(matrix.node.*, ', ')", "case(true, 1, 2)", "case(false, 1, true, 2, 3)",
17+ "((((((((((1))))))))))", "!!!!!!true", "1 || 2 && 3 == 4 < 5", "hashFiles('**/*.go', '!vendor/**')", "success() && always()", "\n\t \r", "\u00a0", "'😀' == '\U00010000'",
18+ "=", "&", "|", "!=", "<=", ">=", "()", "(1", "1)", "a.", ".a", "a..b", "a[", "a[]", "a[*]", "*", "a.*.*", "1.2.3", "--1", "1_000", "'\xed\xa0\x80'",
19+ "format('{0:x}{', 1)", "format('}}{{', 1)", "contains(1,2,3)", "ß == 'SS'", "İ", "'ı' == 'I'", "'\u03a3' < 'z'",
20+}
21+
22+func addSeeds(f *testing.F) {
23+ for _, s := range fuzzSeeds {
24+ f.Add(s)
25+ }
26+}
27+
28+// FuzzLex checks that lexing never panics, that it is deterministic, and
29+// that successful lexing yields ordered tokens ending with exactly one EOF.
30+func FuzzLex(f *testing.F) {
31+ addSeeds(f)
32+ f.Fuzz(func(t *testing.T, s string) {
33+ toks, err := Lex(s)
34+ toks2, err2 := Lex(s)
35+ if (err == nil) != (err2 == nil) || len(toks) != len(toks2) {
36+ t.Fatal("lexing is not deterministic")
37+ }
38+ if err != nil {
39+ if _, ok := err.(*Error); !ok {
40+ t.Fatalf("error is %T, not *Error", err)
41+ }
42+ return
43+ }
44+ if len(toks) == 0 || toks[len(toks)-1].Type != TokenEOF {
45+ t.Fatal("token stream does not end with EOF")
46+ }
47+ for i, tk := range toks {
48+ if tk.Type == TokenEOF && i != len(toks)-1 {
49+ t.Fatal("EOF in the middle of the token stream")
50+ }
51+ if tk.Type != TokenEOF && tk.Lexeme == "" {
52+ t.Fatalf("empty lexeme for token %v", tk.Type)
53+ }
54+ if !utf8.ValidString(tk.Lexeme) && utf8.ValidString(s) {
55+ t.Fatalf("invalid UTF-8 lexeme %q from valid input", tk.Lexeme)
56+ }
57+ // The EOF token of the reference starts at a stale offset, so after
58+ // a trailing newline its start column is -1; every other token
59+ // has a column of at least 0.
60+ if tk.Range.Start.Line < 0 || (tk.Type != TokenEOF && tk.Range.Start.Column < 0) || tk.Range.End.Line < tk.Range.Start.Line {
61+ t.Fatalf("bad range %+v", tk.Range)
62+ }
63+ }
64+ })
65+}
66+
67+// FuzzParse checks that parsing never panics, that errors are *Error and
68+// that the depth limit holds: no accepted expression nests deeper than the
69+// parser allows.
70+func FuzzParse(f *testing.F) {
71+ addSeeds(f)
72+ f.Fuzz(func(t *testing.T, s string) {
73+ node, err := Parse(s, []string{"github", "matrix", "env"}, []FuncInfo{{Name: "hashFiles", MinArgs: 1, MaxArgs: -1}, {Name: "success"}})
74+ if err != nil {
75+ if _, ok := err.(*Error); !ok {
76+ t.Fatalf("error is %T, not *Error", err)
77+ }
78+ if node != nil {
79+ t.Fatal("node returned with an error")
80+ }
81+ return
82+ }
83+ if node == nil && strings.TrimFunc(s, func(r rune) bool { return r == ' ' || r == '\t' || r == '\r' || r == '\n' }) != "" {
84+ t.Fatalf("empty tree for non-blank input %q", s)
85+ }
86+ if d := nodeDepth(node); d > 2*MaxParserDepth+2 {
87+ t.Fatalf("tree depth %d exceeds the parser limit", d)
88+ }
89+ })
90+}
91+
92+func nodeDepth(n Node) int {
93+ switch n := n.(type) {
94+ case *Unary:
95+ return 1 + nodeDepth(n.Expr)
96+ case *Binary:
97+ return 1 + max(nodeDepth(n.Left), nodeDepth(n.Right))
98+ case *Logical:
99+ d := 0
100+ for _, a := range n.Args {
101+ d = max(d, nodeDepth(a))
102+ }
103+ return 1 + d
104+ case *Grouping:
105+ return 1 + nodeDepth(n.Group)
106+ case *IndexAccess:
107+ return 1 + max(nodeDepth(n.Expr), nodeDepth(n.Index))
108+ case *FunctionCall:
109+ d := 0
110+ for _, a := range n.Args {
111+ d = max(d, nodeDepth(a))
112+ }
113+ return 1 + d
114+ }
115+ return 1
116+}
117+
118+// FuzzEvaluate checks that evaluating an arbitrary parsed expression never
119+// panics, always returns a usable value or an *EvalError, and leaves the
120+// context untouched.
121+func FuzzEvaluate(f *testing.F) {
122+ addSeeds(f)
123+ ctx := EvalContext{
124+ Contexts: map[string]Value{
125+ "github": mustJSON(f, `{"event_name":"push","ref":"refs/heads/main","event":{"commits":[{"id":"1","added":["a"]},{"id":"2"},{"x":[[1],[2,3]]}],"n":12,"nested":[[1,2],[3]]}}`),
126+ "matrix": mustJSON(f, `{"node":[14,16],"os":"linux","o":{"k":null}}`),
127+ "env": mustJSON(f, `{"A":"","B":"1","C":"x","J":"{\"a\":[1,2]}"}`),
128+ },
129+ Funcs: map[string]Func{
130+ "hashfiles": func(args []Value) (Value, error) { return String("h"), nil },
131+ "success": func([]Value) (Value, error) { return Boolean(true), nil },
132+ },
133+ }
134+ before := map[string]string{}
135+ for k, v := range ctx.Contexts {
136+ before[k] = ToJSON(v)
137+ }
138+ f.Fuzz(func(t *testing.T, s string) {
139+ node, err := Parse(s, []string{"github", "matrix", "env"}, []FuncInfo{{Name: "hashFiles", MinArgs: 1, MaxArgs: -1}, {Name: "success"}})
140+ if err != nil || node == nil {
141+ return
142+ }
143+ v, err := Evaluate(node, ctx)
144+ if err != nil {
145+ if _, ok := err.(*EvalError); !ok {
146+ t.Fatalf("error is %T, not *EvalError", err)
147+ }
148+ return
149+ }
150+ if v == nil {
151+ t.Fatal("nil value without error")
152+ }
153+ _ = CoerceString(v)
154+ _ = Truthy(v)
155+ j := ToJSON(v)
156+ if !utf8.ValidString(j) {
157+ t.Fatalf("toJSON produced invalid UTF-8: %q", j)
158+ }
159+ if _, err := FromJSON([]byte(j)); err != nil {
160+ t.Fatalf("toJSON output does not parse: %v\n%s", err, j)
161+ }
162+ for k, c := range ctx.Contexts {
163+ if ToJSON(c) != before[k] {
164+ t.Fatalf("context %s was modified", k)
165+ }
166+ }
167+ })
168+}
169+
170+// FuzzFromJSON checks that FromJSON never panics and that whatever it
171+// accepts prints as JSON that parses back to the same text.
172+func FuzzFromJSON(f *testing.F) {
173+ for _, s := range []string{`null`, `{"a":[1,2,{"b":null}]}`, `"\ud83d\ude00"`, `{"1":1,"a":2,"01":3}`, `[1e999]`, `-0`, `"\ud800"`, `{"a":1,"A":2}`, `[`, `{"a"`, "\"\x01\""} {
174+ f.Add(s)
175+ }
176+ f.Fuzz(func(t *testing.T, s string) {
177+ v, err := FromJSON([]byte(s))
178+ if err != nil {
179+ return
180+ }
181+ j := ToJSON(v)
182+ v2, err := FromJSON([]byte(j))
183+ if err != nil {
184+ t.Fatalf("round trip failed: %v\n%s", err, j)
185+ }
186+ if j2 := ToJSON(v2); j2 != j {
187+ t.Fatalf("round trip changed output:\n%s\n%s", j, j2)
188+ }
189+ })
190+}
A expr/gen/golden.mjs +69 -0
@@ -0,0 +1,69 @@
1+// SPDX-License-Identifier: MIT
2+//
3+// Writes the golden files of TestToJSONGolden: for each input testdata/tojson/NAME.json
4+// it writes NAME.golden, which is what the reference implementation
5+// (@actions/expressions) prints for toJSON(fromJSON(input)), that is
6+// JSON.stringify(JSON.parse(input, reviver), replacer, " ").
7+//
8+// node gen/golden.mjs /path/to/@actions/expressions/dist testdata/tojson
9+//
10+// New inputs are added by hand to that directory (and listed in INPUTS below
11+// when they are generated rather than written out).
12+
13+import { pathToFileURL } from "node:url";
14+import path from "node:path";
15+import fs from "node:fs";
16+
17+const dist = path.resolve(process.argv[2]);
18+const dir = process.argv[3];
19+const { data } = await import(pathToFileURL(path.join(dist, "index.js")).href);
20+
21+const INPUTS = {
22+ null: "null",
23+ true: "true",
24+ false: "false",
25+ zero: "0",
26+ negzero: "-0",
27+ int: "42",
28+ negative: "-17",
29+ float: "1.5",
30+ exp_small: "1e-7",
31+ exp_large: "1e21",
32+ exp_20: "1e20",
33+ exp_pos: "1E2",
34+ precision: "0.30000000000000004",
35+ big_int: "12345678901234567890",
36+ max_double: "1.7976931348623157e308",
37+ min_double: "5e-324",
38+ overflow: "1e999",
39+ string_empty: '""',
40+ string_plain: '"hello"',
41+ string_escapes: '"quote \\" backslash \\\\ slash \\/ nl \\n cr \\r tab \\t bs \\b ff \\f"',
42+ string_control: '"\\u0000\\u0001\\u001f\\u007f\\u0080"',
43+ string_unicode: '"h\\u00e9llo \\u4e16\\u754c \\ud83d\\ude00 \\u2028\\u2029"',
44+ string_lone_surrogate: '"\\ud800"',
45+ array_empty: "[]",
46+ array_flat: "[1,2,3]",
47+ array_mixed: '[null,true,"a",1.5,[],{}]',
48+ array_nested: "[[1,[2,[3,[]]]],[]]",
49+ object_empty: "{}",
50+ object_flat: '{"a":1,"b":"two","c":null}',
51+ object_nested: '{"a":{"b":{"c":{"d":[1,{"e":2}]}}},"f":[]}',
52+ object_order: '{"b":1,"a":2,"c":3}',
53+ object_numeric_keys: '{"b":1,"10":2,"2":3,"a":4,"01":5,"1":6,"4294967294":7,"4294967295":8,"-1":9}',
54+ object_case_dup: '{"a":1,"A":2,"b":3,"B":4}',
55+ object_dup_key: '{"a":1,"b":2,"a":3}',
56+ object_empty_key: '{"":1,"a":2}',
57+ object_key_escapes: '{"quote\\"key":1,"new\\nline":2,"\\u00e9":3}',
58+ github_event: '{"action":"opened","number":1,"pull_request":{"id":1,"draft":false,"labels":[{"name":"bug","color":"d73a4a"},{"name":"docs","color":null}],"head":{"ref":"feat/x","sha":"abc123"}},"repository":{"full_name":"octo/repo","private":true,"topics":[]}}',
59+};
60+
61+fs.mkdirSync(dir, { recursive: true });
62+for (const [name, text] of Object.entries(INPUTS)) {
63+ fs.writeFileSync(path.join(dir, name + ".json"), text + "\n");
64+}
65+for (const f of fs.readdirSync(dir).filter(f => f.endsWith(".json")).sort()) {
66+ const text = fs.readFileSync(path.join(dir, f), "utf8");
67+ const value = JSON.parse(text, data.reviver);
68+ fs.writeFileSync(path.join(dir, f.replace(/\.json$/, ".golden")), JSON.stringify(value, data.replacer, " "));
69+}
@@ -0,0 +1,69 @@
1+// SPDX-License-Identifier: MIT
2+//
3+// Writes the golden files of TestToJSONGolden: for each input testdata/tojson/NAME.json
4+// it writes NAME.golden, which is what the reference implementation
5+// (@actions/expressions) prints for toJSON(fromJSON(input)), that is
6+// JSON.stringify(JSON.parse(input, reviver), replacer, " ").
7+//
8+// node gen/golden.mjs /path/to/@actions/expressions/dist testdata/tojson
9+//
10+// New inputs are added by hand to that directory (and listed in INPUTS below
11+// when they are generated rather than written out).
12+
13+import { pathToFileURL } from "node:url";
14+import path from "node:path";
15+import fs from "node:fs";
16+
17+const dist = path.resolve(process.argv[2]);
18+const dir = process.argv[3];
19+const { data } = await import(pathToFileURL(path.join(dist, "index.js")).href);
20+
21+const INPUTS = {
22+ null: "null",
23+ true: "true",
24+ false: "false",
25+ zero: "0",
26+ negzero: "-0",
27+ int: "42",
28+ negative: "-17",
29+ float: "1.5",
30+ exp_small: "1e-7",
31+ exp_large: "1e21",
32+ exp_20: "1e20",
33+ exp_pos: "1E2",
34+ precision: "0.30000000000000004",
35+ big_int: "12345678901234567890",
36+ max_double: "1.7976931348623157e308",
37+ min_double: "5e-324",
38+ overflow: "1e999",
39+ string_empty: '""',
40+ string_plain: '"hello"',
41+ string_escapes: '"quote \\" backslash \\\\ slash \\/ nl \\n cr \\r tab \\t bs \\b ff \\f"',
42+ string_control: '"\\u0000\\u0001\\u001f\\u007f\\u0080"',
43+ string_unicode: '"h\\u00e9llo \\u4e16\\u754c \\ud83d\\ude00 \\u2028\\u2029"',
44+ string_lone_surrogate: '"\\ud800"',
45+ array_empty: "[]",
46+ array_flat: "[1,2,3]",
47+ array_mixed: '[null,true,"a",1.5,[],{}]',
48+ array_nested: "[[1,[2,[3,[]]]],[]]",
49+ object_empty: "{}",
50+ object_flat: '{"a":1,"b":"two","c":null}',
51+ object_nested: '{"a":{"b":{"c":{"d":[1,{"e":2}]}}},"f":[]}',
52+ object_order: '{"b":1,"a":2,"c":3}',
53+ object_numeric_keys: '{"b":1,"10":2,"2":3,"a":4,"01":5,"1":6,"4294967294":7,"4294967295":8,"-1":9}',
54+ object_case_dup: '{"a":1,"A":2,"b":3,"B":4}',
55+ object_dup_key: '{"a":1,"b":2,"a":3}',
56+ object_empty_key: '{"":1,"a":2}',
57+ object_key_escapes: '{"quote\\"key":1,"new\\nline":2,"\\u00e9":3}',
58+ github_event: '{"action":"opened","number":1,"pull_request":{"id":1,"draft":false,"labels":[{"name":"bug","color":"d73a4a"},{"name":"docs","color":null}],"head":{"ref":"feat/x","sha":"abc123"}},"repository":{"full_name":"octo/repo","private":true,"topics":[]}}',
59+};
60+
61+fs.mkdirSync(dir, { recursive: true });
62+for (const [name, text] of Object.entries(INPUTS)) {
63+ fs.writeFileSync(path.join(dir, name + ".json"), text + "\n");
64+}
65+for (const f of fs.readdirSync(dir).filter(f => f.endsWith(".json")).sort()) {
66+ const text = fs.readFileSync(path.join(dir, f), "utf8");
67+ const value = JSON.parse(text, data.reviver);
68+ fs.writeFileSync(path.join(dir, f.replace(/\.json$/, ".golden")), JSON.stringify(value, data.replacer, " "));
69+}
A expr/gen/oracle.mjs +317 -0
@@ -0,0 +1,317 @@
1+// SPDX-License-Identifier: MIT
2+//
3+// Generates testdata/oracle.json.gz: the results of the reference implementation
4+// (@actions/expressions 0.3.61, MIT, GitHub) on a corpus of expressions. The Go
5+// tests (oracle_test.go) replay the corpus and compare token by token what
6+// GitHub's JavaScript library does with what this package does.
7+//
8+// node gen/oracle.mjs /path/to/@actions/expressions/dist | gzip -9 -n > testdata/oracle.json.gz
9+//
10+// The corpus is hand-written edge cases plus a seeded pseudo-random walk over
11+// the grammar and over mutations of it, so the file is reproducible.
12+
13+import { pathToFileURL } from "node:url";
14+import path from "node:path";
15+
16+const dist = path.resolve(process.argv[2] ?? "node_modules/@actions/expressions/dist");
17+const ex = await import(pathToFileURL(path.join(dist, "index.js")).href);
18+const { Lexer, Parser, Evaluator, data } = ex;
19+
20+// ---- contexts -------------------------------------------------------------
21+
22+const contexts = {
23+ github: {
24+ event_name: "push",
25+ ref: "refs/heads/main",
26+ sha: "0123abc",
27+ actor: "Octocat",
28+ repository: "octo/Hello-World",
29+ run_number: 7,
30+ run_id: "1658821493",
31+ event: {
32+ number: 12,
33+ commits: [
34+ { id: "1", message: "fix: bug", author: { name: "Ann", email: "a@x" }, added: ["a.txt"] },
35+ { id: "2", message: "feat: thing", author: { name: "Bob" }, added: [] },
36+ { id: "3", message: "Chore", added: ["b", "c"] },
37+ ],
38+ head_commit: { message: "hello", timestamp: null },
39+ labels: [{ name: "bug" }, { name: "Docs" }, { name: "help wanted" }],
40+ pull_request: { draft: false, title: "A title", labels: [{ name: "x" }] },
41+ nested: [[1, 2], [3], [], [4, 5, 6]],
42+ },
43+ "weird-key": 1,
44+ "a b": "space",
45+ },
46+ env: { FOO: "bar", EMPTY: "", NUM: "42", TRUE: "true", JSONTEXT: '{"a":[1,2,3]}', Mixed: "MiXeD", Float: "1.50", Hex: "0x1F" },
47+ matrix: {
48+ os: "ubuntu",
49+ node: [14, 16, 18],
50+ include: [{ os: "windows", node: 20 }, { os: "mac", experimental: true }],
51+ v: 1.5,
52+ flag: true,
53+ nothing: null,
54+ ver: "3.10",
55+ big: 12345678901234567890,
56+ neg: -0.25,
57+ tiny: 1e-7,
58+ zero: 0,
59+ obj: { k: "v" },
60+ },
61+ inputs: { name: "World", count: 3, deploy: false, list: ["a", "b"], empty: {} },
62+ steps: { "10": { outputs: { r: "ten" } }, "2": { outputs: { r: "two" } }, build: { outputs: { r: "b", Other: "o" }, outcome: "success" } },
63+ needs: {},
64+ vars: { A: "1", B: "two" },
65+};
66+const ctxNames = Object.keys(contexts);
67+
68+const extFuncs = [
69+ { name: "success", description: "", minArgs: 0, maxArgs: 0, call: () => new data.BooleanData(true) },
70+ { name: "always", description: "", minArgs: 0, maxArgs: 0, call: () => new data.BooleanData(true) },
71+ {
72+ name: "hashFiles",
73+ description: "",
74+ minArgs: 1,
75+ maxArgs: 255,
76+ call: (...a) => new data.StringData("hash:" + a.map(x => x.coerceString()).join("|")),
77+ },
78+];
79+const extInfos = extFuncs.map(f => ({ name: f.name, description: f.description, minArgs: f.minArgs, maxArgs: f.maxArgs }));
80+const funcMap = new Map(extFuncs.map(f => [f.name.toLowerCase(), f]));
81+
82+const rootDict = new data.Dictionary(
83+ ...Object.keys(contexts).map(k => ({ key: k, value: JSON.parse(JSON.stringify(contexts[k]), data.reviver) }))
84+);
85+
86+// ---- running the reference -------------------------------------------------
87+
88+function kindOf(v) {
89+ switch (v.kind) {
90+ case data.Kind.Null: return "null";
91+ case data.Kind.Boolean: return "boolean";
92+ case data.Kind.Number: return "number";
93+ case data.Kind.String: return "string";
94+ case data.Kind.Array: return "array";
95+ case data.Kind.Dictionary: return "object";
96+ }
97+ return "?";
98+}
99+
100+function run(expr) {
101+ const out = { e: expr };
102+ let tokens;
103+ try {
104+ tokens = new Lexer(expr).lex().tokens;
105+ } catch (e) {
106+ if (!(e instanceof Error) || e instanceof TypeError || e instanceof RangeError) { out.skip = String(e); return out; }
107+ out.lexErr = e.message;
108+ return out;
109+ }
110+ out.tokens = tokens.map(t => [t.type, t.lexeme, t.range.start.line, t.range.start.column, t.range.end.line, t.range.end.column,
111+ t.type === 17 ? (Number.isNaN(t.value) ? "NaN" : String(t.value)) : t.type === 18 ? t.value : null]);
112+ let ast;
113+ try {
114+ ast = new Parser(tokens, ctxNames, extInfos).parse();
115+ } catch (e) {
116+ if (e instanceof TypeError || e instanceof RangeError) { out.skip = String(e); return out; }
117+ out.parseErr = e.message;
118+ if (e.pos) out.pos = [e.pos.line, e.pos.column];
119+ return out;
120+ }
121+ if (ast === undefined) { out.empty = true; return out; }
122+ let res;
123+ try {
124+ res = new Evaluator(ast, rootDict, funcMap).evaluate();
125+ } catch (e) {
126+ if (e instanceof TypeError || e instanceof RangeError) { out.skip = String(e); return out; }
127+ out.evalErr = e.message;
128+ return out;
129+ }
130+ if (res === undefined) { out.skip = "undefined result"; return out; }
131+ out.r = { k: kindOf(res), s: res.coerceString(), j: JSON.stringify(res, data.replacer, " ") };
132+ return out;
133+}
134+
135+// ---- corpus ----------------------------------------------------------------
136+
137+const corpus = [];
138+const add = (...xs) => corpus.push(...xs);
139+
140+// literals, numbers
141+add("true", "false", "null", "NaN", "Infinity", "-Infinity", "+Infinity", "1", "-1", "+1", "1.5", ".5", "-.5", "5.", "1e3", "1E-3", "1e21", "1e-7",
142+ "0x10", "0X1f", "0o17", "0b101", "-0x10", "0x", "0b2", "1_000", "1e", "1e+", "--1", "+-1", "-", "+", ".", "..", "1.2.3", "0.1", "0.30000000000000004",
143+ "100000000000000000000", "1000000000000000000000", "123456789012345678", "-0", "0", "00", "007", "1e308", "1e309", "-1e309", "4.9e-324",
144+ "0.000001", "0.0000001", "123456789.123456789", "0.1234567890123456789", "9.9999999999999995", "0.9999999999999999", "0.99999999999999999",
145+ "1.0000000000000002", "2.5", "0.5", "1.005", "1.45", "8.345", "0.000000000000005", "0.0000000000000005", "0.0000000000000004999",
146+ "1e15", "1e16", "123456789012345.6", "12345678901234567.5");
147+for (const n of ["0.1", "0.5", "1.5", "2.5", "1e21", "1e-7", "1e300", "-1e-7", "0.000001", "123456789012345678901", "5e-324", "1.7976931348623157e308",
148+ "0.30000000000000004", "1.005", "0.1234567890123456", "0.12345678901234567", "1234567.1234567890123456", "-0.0000000000000005", "0.0000000000000005",
149+ "NaN", "Infinity", "-Infinity", "100", "1e20", "123e-20", "99999999999999999999", "0.000000000000001", "0.0000000000000011", "4503599627370497.5",
150+ "0.45", "0.55", "-1.5", "1.0000000000000005", "1.0000000000000004", "10.000000000000005"]) {
151+ add(`format('{0}', ${n})`, `toJSON(${n})`, `${n} == '${n}'`, `'${n}' == ${n}`, `join(fromJSON('[${n}]'), ',')`);
152+}
153+
154+// strings
155+add("'a'", "''", "''''", "'it''s'", "'a''b''c'", "'''a'", "'a'''", "'a''''b'", "'unterminated", "'", "'a'b", "'a' 'b'", "'multi\nline'",
156+ "'\u00e9'", "'\u{1F600}'", "'\u{1F600}' == '\u{1F600}'", "\"dq\"", "'tab\there'");
157+
158+// whitespace & lexing oddities
159+add(" true ", "\ttrue\r\n", "\n\ntrue", "\u00a0true", "true\u00a0", "tr\u00a0ue", "\vtrue", "true\u2028", "true\ufeff", "a", "github", "GITHUB", "GitHub.Event_Name",
160+ "github .event_name", "github. event_name", "github.\nevent_name", "github\n.event_name", "github.event_name.", "github..event_name", ".github", "github.",
161+ "github[", "github[]", "github['event_name'", "github['event_name']]", "github[0]", "github[-1]", "github[1.5]", "github['0']", "github[true]", "github[null]",
162+ "github[nan]", "github[NaN]", "github[Infinity]", "github[github]", "github[github.event]", "github['EVENT_NAME']", "github[ 'event_name' ]",
163+ "=", "==", "===", "= =", "&", "&&", "&&&", "|", "||", "|||", "!", "!!", "!!!true", "!=", "!==", "!==true", "<", "<=", "<=>", ">", ">=", ">==",
164+ "1 = 1", "1 & 1", "1 | 1", "1 &&", "&& 1", "1 && && 1", "1 ||", "|| 1", "@", "#", "$", "1 + 1", "1 - 1", "1 * 1", "1 / 1", "a-b", "a_b", "a1", "1a", "_a", "-a", "a-",
165+ "github-", "github-x", "github.event-name", "github.event_name-x", "github.0", "github.1a", "github.-x", "github.true", "github.null", "github.NaN", "github.Infinity",
166+ "github.false", "env.TRUE", "env.true", "env.null", "(", ")", "()", "(())", "(1", "1)", "((1)", "(1))", "(1)(2)", "1 2", "1,2", ",", "1,", "[", "]", "[]", "[1]", "{}",
167+ "*", "*.a", "1.*", "'a'.b", "'a'[0]", "(github).event_name", "(github)['event_name']", "(github.event).number", "(1).a", "(1)[0]", "1[0]", "true.a", "null.a", "null[0]",
168+ "true[0]", "'abc'[0]", "'abc'.length", "(('a'))[0]", "toJSON(1)[0]", "fromJSON('[1,2]')[1]", "fromJSON('{\"a\":1}').a", "fromJSON('{\"a\":1}')['A']", "fromJSON('[[1]]')[0][0]");
169+
170+// operators, coercions
171+const vals = ["null", "true", "false", "0", "1", "-1", "0.5", "NaN", "Infinity", "''", "'0'", "'1'", "' 1 '", "'abc'", "'ABC'", "'a'", "'b'", "'true'", "'null'", "'NaN'", "'Infinity'", "'0x10'", "'1e2'", "'-1'", "'1.0'", "'\u00df'", "'SS'", "'ss'", "'\u0131'", "'I'", "'i'", "'\u0130'", "'\u03c3'", "'\u03c2'", "'\u03a3'", "'\ufb03'", "'\u212a'", "'k'", "'K'", "'\u{1F600}'", "'\ue000'", "'\u{10000}'", "github", "github.event.commits", "github.event.commits[0]", "env", "matrix.node", "matrix.obj", "inputs.empty", "fromJSON('[]')", "fromJSON('{}')", "github.event.commits.*", "github.event.nope.*", "matrix.nothing", "env.NUM", "env.EMPTY"];
172+for (const a of vals) {
173+ add(`!${a}`, `!!${a}`, `${a} || 'dflt'`, `${a} && 'then'`, `format('{0}', ${a})`, `toJSON(${a})`, `join(${a})`, `join(${a}, '-')`, `fromJSON(${a})`, `case(${a}, 'x', 'y', 'z')`, `case(${a} == ${a}, 1, 2, 3)`);
174+ for (const b of vals) {
175+ for (const op of ["==", "!=", "<", "<=", ">", ">="]) add(`${a} ${op} ${b}`);
176+ add(`contains(${a}, ${b})`, `startsWith(${a}, ${b})`, `endsWith(${a}, ${b})`);
177+ }
178+}
179+
180+// precedence and chains
181+add("true || false && false", "(true || false) && false", "false && true || true", "1 == 1 == true", "1 < 2 == true", "true == 1 < 2", "!1 == 2", "!(1 == 2)", "!1 < 2", "1 < !2", "!true == false",
182+ "1 || 2 || 3", "0 || '' || null", "0 || '' || 'x'", "1 && 2 && 3", "1 && 0 && 3", "null && 1", "null || null", "'a' && 'b' || 'c'", "'' && 'b' || 'c'", "a && b", "true && (false || true)",
183+ "1 == 1 && 2 == 2 || 3 == 4", "1 != 1 || 2 != 2", "github.event_name == 'push' && contains(github.ref, 'main')", "github.event_name != 'push' || !contains(github.ref, 'dev')",
184+ "contains(github.event.commits.*.message, 'fix: bug')", "contains(github.event.commits.*.message, 'FIX: BUG')", "contains(toJSON(github.event.commits), 'Bob')",
185+ "github.event.commits[0].author.name", "github.event.commits[1].author.email", "github.event.commits[2].author.name", "github.event.commits[3]", "github.event.commits[-1]", "github.event.commits['1'].id",
186+ "github.event.commits[1.9].id", "github.event.commits['1.9'].id", "github.event.commits[' 1 '].id", "github.event.commits[true]", "github.event.commits[false].id", "github.event.commits[null].id",
187+ "github.event.commits[1e0].id", "github.event.commits[0x1].id", "github.event.commits[Infinity]", "github.event.commits[-0].id", "github.event.commits[1e400]", "github.event.commits[1e20]",
188+ "github.event.commits.*", "github.event.commits.*.id", "github.event.commits.*.author", "github.event.commits.*.author.name", "github.event.commits.*.author.email", "github.event.commits.*.added",
189+ "github.event.commits.*.added.*", "github.event.commits.*.added[0]", "github.event.commits.*.added[1]", "github.event.commits.*.*", "github.event.commits[*].id", "github.event.commits[*]",
190+ "github.event.commits[*][*]", "github.event.commits.*[0]", "github.event.commits.*['id']", "github.event.commits.*.author.*", "github.*", "github.event.*", "github.*.number", "github.*.*", "github.event.*.*",
191+ "github.event.nested.*", "github.event.nested.*.*", "github.event.nested[*][0]", "github.event.nested.*[1]", "github.event.nested.*.*.*", "github.event.nested[3][1]", "github.event.nested[2][0]",
192+ "github.event.labels.*.name", "join(github.event.labels.*.name, ', ')", "join(github.event.labels.*.name)", "toJSON(github.event.labels.*.name)", "toJSON(github.event.labels.*)",
193+ "github.event.head_commit.*", "github.event.head_commit.message", "github.event.head_commit.timestamp", "github.event.head_commit.timestamp.x", "github.event.head_commit.timestamp.*",
194+ "github.event.pull_request.draft", "github.event.pull_request.draft == false", "!github.event.pull_request.draft", "github.event.pull_request.labels.*.name",
195+ "github['weird-key']", "github.weird-key", "github['a b']", "github.a b", "github.EVENT.NUMBER", "github.Event['Number']", "github.event.number == 12", "github.event.number == '12'", "github.event.number > 5",
196+ "github.event.number > '5'", "github.event.number > 'x'", "github.run_number", "github.run_id", "github.run_id == 1658821493", "github.run_id > 1658821492",
197+ "env.FOO", "env.foo", "env.Mixed == 'mixed'", "env.NUM == 42", "env.NUM > 41", "env.NUM + 1", "env.Float == 1.5", "env.Hex == 31", "env.TRUE == true", "env.EMPTY == null", "env.EMPTY == false", "env.EMPTY",
198+ "fromJSON(env.JSONTEXT).a[1]", "fromJSON(env.JSONTEXT).a.*", "toJSON(fromJSON(env.JSONTEXT))",
199+ "matrix.os", "matrix.node", "matrix.node[0]", "matrix.node.*", "matrix.include.*.os", "matrix.include[0].node", "matrix.include[1].experimental", "matrix.v", "matrix.flag", "matrix.nothing", "matrix.neg", "matrix.tiny",
200+ "matrix.big", "matrix.big == 12345678901234567890", "format('{0}', matrix.big)", "format('{0} {1}', matrix.tiny, matrix.neg)", "matrix.ver", "matrix.ver == 3.1", "matrix.ver == '3.10'", "matrix.zero || 'z'",
201+ "matrix.obj", "matrix.obj == matrix.obj", "matrix.obj != matrix.obj", "matrix.node == matrix.node", "github.event.commits == github.event.commits", "github.event.commits.* == github.event.commits.*",
202+ "matrix.obj.k", "matrix.obj['k']", "matrix.obj[0]", "matrix.obj.*", "matrix.node['0']", "matrix.node[0] == 14", "matrix.node[0] < matrix.node[1]", "contains(matrix.node, 16)", "contains(matrix.node, '16')", "contains(matrix.node, 17)",
203+ "contains(matrix.include.*.os, 'MAC')", "inputs.name", "inputs.count", "inputs.count == 3", "inputs.deploy", "inputs.deploy == false", "!inputs.deploy", "inputs.list", "inputs.list.*", "inputs.empty", "inputs.empty.*", "inputs.empty.a",
204+ "inputs.missing", "inputs.missing.deeper", "inputs.missing[0]", "inputs.missing.*", "inputs.missing.*.x", "inputs['missing']['x']", "steps.build.outputs.r", "steps.BUILD.OUTPUTS.OTHER", "steps.build.outcome == 'success'", "steps.build.outputs.*",
205+ "steps.10.outputs.r", "steps['10'].outputs.r", "steps[10].outputs.r", "steps[2].outputs.r", "steps.*", "toJSON(steps)", "toJSON(steps.*.outputs)", "steps.*.outputs.r", "needs", "needs.x", "needs.x.result", "needs.*", "toJSON(needs)", "toJSON(needs.*)",
206+ "vars.A", "vars.B", "vars.A == 1", "vars.C || 'default'", "toJSON(vars)", "toJSON(github.event.commits[0])", "toJSON(github.event)", "toJSON(contains)", "toJSON(github)", "toJSON(matrix)", "toJSON(inputs)", "toJSON(env)",
207+ "toJSON(github.event.commits.*.added)", "toJSON(github.event.nested)", "toJSON(github.event.nested.*)", "toJSON(github.event.nested.*.*)", "toJSON(matrix.big)", "toJSON(matrix.tiny)", "toJSON(matrix.neg)", "toJSON(matrix.nothing)", "toJSON(inputs.empty)", "toJSON(inputs.list)");
208+
209+// functions: arity and behaviours
210+add("contains()", "contains(1)", "contains(1,2)", "contains(1,2,3)", "contains('a','b',)", "contains(,)", "contains('abc','B')", "contains('abc','')", "contains('', '')", "contains('', 'a')", "contains(null, '')", "contains(null, null)", "contains(1234, 23)", "contains(true, 'RU')", "contains(github, 'a')",
211+ "contains('a', github)", "contains(github.event.commits, 'x')", "contains('\u0130', 'i')", "contains('I', '\u0131')", "contains('\u00df', 'ss')", "contains('SS', '\u00df')", "contains('\u03a3', '\u03c3')", "contains('\u03c2', '\u03c3')", "contains('A\u03a3', 'a\u03c2')", "contains('\u212a', 'k')",
212+ "contains(fromJSON('[1,\"2\",null]'), null)", "contains(fromJSON('[1,\"2\",null]'), '')", "contains(fromJSON('[1,\"2\",null]'), 2)", "contains(fromJSON('[]'), null)", "contains(fromJSON('[[1]]'), fromJSON('[1]'))", "contains(github.event.commits.*.id, 2)", "contains(github.event.commits.*.id, '2')",
213+ "startsWith()", "startsWith('a')", "startsWith('abc','AB')", "startsWith('abc','')", "startsWith('', '')", "startsWith(null, '')", "startsWith(123, 12)", "startsWith(github, 'a')", "startsWith('\u0131', 'I')", "startsWith('\u0131', '\u0131')", "startsWith('i', '\u0131')", "startsWith('\u00df', 'ss')", "startsWith('SS', '\u00df')",
214+ "endsWith('abc','BC')", "endsWith('abc','')", "endsWith(null, 'a')", "endsWith(1.50, '5')", "endsWith(1.50, 5)", "endsWith('\u00df', 'S')", "endsWith('abc', 'abcd')",
215+ "format()", "format('a')", "format('')", "format(null)", "format(1)", "format(true)", "format('{0}')", "format('{0}', 1)", "format('{0}', 1, 2)", "format('{1}', 1)", "format('{1}', 1, 2)", "format('{0}{0}', 'x')", "format('{0}{1}{0}', 'a', 'b')", "format('{{')", "format('}}')", "format('{{}}')", "format('{{0}}', 1)",
216+ "format('{{{0}}}', 1)", "format('{', 1)", "format('}', 1)", "format('{}', 1)", "format('{ 0}', 1)", "format('{0 }', 1)", "format('{0:x}', 1)", "format('{0,5}', 1)", "format('{0 anything}', 1)", "format('{00}', 1)", "format('{01}', 1, 2)", "format('{-1}', 1)", "format('{a}', 1)", "format('{0', 1)", "format('0}', 1)", "format('{{0}', 1)", "format('{0}}', 1)",
217+ "format('{0}}}', 1)", "format('a{0}b{1}c', 1, 2)", "format('{0}', null)", "format('{0}', true)", "format('{0}', github)", "format('{0}', github.event.commits)", "format('{0}', matrix.node.*)", "format('{12345678901234567890123}', 1)", "format('{99999999999999999999999999999999999}', 1)", "format('{0}\n{0}', 'a')", "format('x}', 1)", "format('}{', 1)", "format('{{{{', 1)", "format('{}}', 1)", "format('{0}{{', 1)", "format('{{{0}', 1)",
218+ "format('{0}', 'a', 'b', 'c')", "format('{3}', 0, 1, 2, 3)", "format('{2}', 0, 1, 2, 3)", "format('{255}', 1)", "format('\u{1F600}{0}\u{1F600}', 'x')", "format('{0}', '\u{1F600}')", "format('{ {0}', 1)", "format(' {0} ', 1)",
219+ "fromJSON()", "fromJSON('1','2')", "fromJSON('')", "fromJSON(' ')", "fromJSON('\n')", "fromJSON(null)", "fromJSON('null')", "fromJSON('true')", "fromJSON('false')", "fromJSON('1')", "fromJSON('-1.5e2')", "fromJSON('\"s\"')", "fromJSON('[1,2,3]')", "fromJSON('{\"a\":1}')", "fromJSON('{\"a\":1,\"A\":2}')", "fromJSON('{\"a\":1,\"a\":2}')", "toJSON(fromJSON('{\"a\":1,\"A\":2}'))", "toJSON(fromJSON('{\"a\":1,\"a\":2,\"b\":3}'))",
220+ "toJSON(fromJSON('{\"b\":1,\"2\":2,\"1\":3,\"a\":4,\"01\":5,\"4294967294\":6,\"4294967295\":7,\"-1\":8}'))", "toJSON(fromJSON('{\"\":1}'))", "toJSON(fromJSON('{\"a\":{\"b\":{\"c\":[]}}}'))", "fromJSON('{')", "fromJSON('{\"a\"')", "fromJSON('[1,]')", "fromJSON('[1 2]')", "fromJSON('{a:1}')", "fromJSON(\"{'a':1}\")", "fromJSON('01')", "fromJSON('1.')", "fromJSON('.5')", "fromJSON('+1')", "fromJSON('NaN')", "fromJSON('Infinity')", "fromJSON('undefined')", "fromJSON('nul')", "fromJSON('truee')",
221+ "fromJSON('1 2')", "fromJSON(' 1 ')", "fromJSON('\"\\u0041\"')", "fromJSON('\"\\ud83d\\ude00\"')", "fromJSON('\"\\x\"')", "fromJSON('\"a\nb\"')", "fromJSON('\"\\/\"')", "fromJSON('1e999')", "fromJSON('-1e999')", "fromJSON('-0')", "toJSON(fromJSON('-0'))", "toJSON(fromJSON('1e999'))", "toJSON(fromJSON('1E2'))", "toJSON(fromJSON('0.1e1'))", "toJSON(fromJSON('123456789012345678901234567890'))",
222+ "toJSON(fromJSON('[1e21,1e-7,0.000001,100,1.5,-2.5e-10]'))", "toJSON(fromJSON('\"\\u0000\\u001f\\u007f\\u2028\\\"\\\\\"'))", "toJSON('\u0000')", "toJSON('\u001f\u007f\u0080\u2028\u2029\ufeff')", "toJSON('\"\\')", "toJSON('a\nb\tc\rd\be\ff')", "toJSON('\u00e9\u00df\u{1F600}')", "toJSON(fromJSON('[]'))", "toJSON(fromJSON('{}'))", "toJSON(fromJSON('[[],{}]'))", "toJSON(fromJSON('{\"a\":[],\"b\":{}}'))", "toJSON(fromJSON('[null]'))",
223+ "toJSON()", "toJSON(1,2)", "toJSON(null)", "toJSON(true)", "toJSON(1)", "toJSON(NaN)", "toJSON(Infinity)", "toJSON(-Infinity)", "toJSON(-0)", "toJSON('x')", "toJSON('')", "toJSON(1e21)", "toJSON(1e-7)", "toJSON(0.1)", "toJSON(123456789012345680000)",
224+ "join()", "join('a')", "join('a','b')", "join(null)", "join(1.50)", "join(true, 'x')", "join(github)", "join(github, 'x')", "join(fromJSON('[]'))", "join(fromJSON('[1,2,3]'))", "join(fromJSON('[1,2,3]'), '')", "join(fromJSON('[1,2,3]'), ', ')", "join(fromJSON('[1,2,3]'), 1)", "join(fromJSON('[1,2,3]'), null)", "join(fromJSON('[1,2,3]'), true)", "join(fromJSON('[1,2,3]'), github)", "join(fromJSON('[1,2,3]'), fromJSON('[]'))", "join(fromJSON('[1,2,3]'), NaN)",
225+ "join(fromJSON('[null,true,\"a\",1.5,[1],{}]'))", "join(matrix.node, '.')", "join(matrix.node.*, '.')", "join(github.event.commits.*.id, '/')", "join(1,2,3)", "join(fromJSON('[\"a\",\"b\"]'), '\u{1F600}')",
226+ "case()", "case(true)", "case(true, 1)", "case(true, 1, 2)", "case(false, 1, 2)", "case(true, 1, false, 2, 3)", "case(false, 1, true, 2, 3)", "case(false, 1, false, 2, 3)", "case(false, 1, false, 2, 3, 4)", "case(false, 1, false, 2, 3, 4, 5)", "case(1, 2, 3)", "case('true', 2, 3)", "case(null, 2, 3)", "case(false, 2, 3, 4)", "case(true, 2, 1, 3)", "case(true, 2, null, 3)", "case(false, 1, 'x', 3)",
227+ "case(true, github.event_name, 'other')", "case(github.event_name == 'push', 'a', github.event_name == 'pr', 'b', 'c')", "CASE(true, 1, 2)", "case(true, matrix.node, 2)", "toJSON(case(true, matrix.node.*, 2))", "case(1 == 1, 'a', 'b')", "case(!0, 'a', 'b')", "case(fromJSON('true'), 'a', 'b')",
228+ "CONTAINS('a','A')", "Contains('a','A')", "STARTSWITH('a','A')", "endswith('a','A')", "FORMAT('{0}',1)", "fromjson('1')", "FROMJSON('1')", "tojson(1)", "TOJSON(1)", "JOIN(fromJSON('[1]'))", "unknown()", "unknown(1)", "Constantine()", "notafunction", "success", "success()", "success(1)", "always()", "ALWAYS()", "hashFiles()", "hashFiles('a')", "hashFiles('a','b')", "hashFiles('**/*.go', '!x')", "HASHFILES('a')", "failure()", "cancelled()",
229+ "success() && hashFiles('a') != ''", "contains(github.ref, 'main') && success()", "always() || false", "hashFiles(github.sha, null, 1.5)", "hashFiles(1,2,3,4,5,6,7,8,9,10)", "format()", "format(1,2,3,4)");
230+add("hashFiles(" + Array.from({ length: 255 }, (_, i) => `'${i}'`).join(",") + ")", "hashFiles(" + Array.from({ length: 256 }, (_, i) => `'${i}'`).join(",") + ")");
231+add("format('{0}'" + ", 1".repeat(254) + ")", "format('{0}'" + ", 1".repeat(255) + ")", "case(" + Array.from({ length: 101 }, (_, i) => i < 100 ? (i % 2 == 0 ? "false" : "1") : "'end'").join(",") + ")");
232+add("contains('a', 'b',)", "contains(1,)", "contains(,1)", "contains('a' 'b')", "contains('a';'b')", "contains(('a'), ('b'))", "contains(contains('a','a'), true)", "format(format('{{0}}'), 5)", "toJSON(toJSON(1))", "fromJSON(toJSON(github.event.commits[0])).author.name", "fromJSON(fromJSON('\"[1]\"'))[0]", "fromJSON('[1,2]')[fromJSON('1')]",
233+ "join(fromJSON(toJSON(github.event.labels.*.name)), '+')", "toJSON(fromJSON(toJSON(github.event.commits)))", "format('{0}', toJSON(null))", "fromJSON(format('[{0},{1}]', 1, 2))[1]", "fromJSON('[{\"a\":1},{\"a\":2},{\"b\":3}]').*.a", "toJSON(fromJSON('[{\"a\":1},{\"a\":2},{\"b\":3}]').*.a)", "toJSON(fromJSON('[{\"a\":[1,2]},{\"a\":[3]}]').*.a.*)", "toJSON(fromJSON('[{\"a\":[1,2]},{\"a\":[3]}]').*.a[0])", "toJSON(fromJSON('[[1,2],[3]]').*[0])", "toJSON(fromJSON('[[1,2],[3]]')[*][1])",
234+ "toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":{\"x\":2}}').*.x)", "toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":{\"x\":2}}')[*].x)", "toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":2}').*.x)", "toJSON(fromJSON('[1,2]').*.x)", "toJSON(fromJSON('1').*)", "toJSON(null.*)", "toJSON('abc'.*)", "toJSON(fromJSON('[]').*.a)", "toJSON(fromJSON('[]').*)", "toJSON(fromJSON('{}').*)");
235+
236+// depth and length limits
237+const nest = (n, open, body, close) => open.repeat(n) + body + close.repeat(n);
238+for (const n of [1, 10, 24, 25, 26, 48, 49, 50, 51, 52, 100, 200]) {
239+ add(nest(n, "(", "1", ")"), "!".repeat(n) + "true", nest(n, "format(", "'x'", ")"), "github" + ".event".repeat(n), "github" + "[0]".repeat(n), "github" + "['event']".repeat(n),
240+ "1" + " || 1".repeat(n), "1" + " && 1".repeat(n), "1" + " == 1".repeat(n), nest(n, "(1 || ", "1", ")"), nest(n, "!(", "1", ")"), nest(n, "(1 && ", "1", ")"), nest(n, "github[", "0", "]"), nest(n, "contains(1, ", "1", ")"));
241+}
242+for (const n of [20990, 21000, 21001, 22000]) {
243+ add("'" + "a".repeat(n - 2) + "'", " ".repeat(n) + "1", "1" + " ".repeat(n - 1), "'" + "\u{1F600}".repeat(Math.floor((n - 2) / 2)) + "'", "'" + "\u{1F600}".repeat(Math.floor((n - 3) / 2)) + "'");
244+}
245+
246+// positions and error messages
247+for (const e of ["1 +", "a b", "github github", "github.event_name ==", "== 1", "(github.event_name", "github.event_name)", "contains('a',", "contains('a'", "contains('a' 'b')", "contains(", "github[0", "github[0)",
248+ "1 ==\n 2 ===\n 3", "true &&\n false ||\n nope", "\n\n bogus", "'a'\n 'b'\n zzz", "true\n&\nfalse", "foo(", "foo", "github.event_name\n .\n ", " ) ", "1 !", "1 !!", "1 ! 1", "!", "! ", "!(", "!)", "(!)", "[1]", "github.[0]", "github.*.", "github.*.*.", "github[*", "github[*]]", "github[* ]", "github[1 2]", "github[,]",
249+ "1 && ( 2 ||", "1\t2", "x\ty", "github\r\n.event", "a\u00a0b", "'x' =", "'x' = 'y'", "!= 1", ">", "<<", ">>", "1 <> 2", "1 >< 2", "1 =< 2", "1 => 2", "contains(github.event_name, 'push'", "contains(github.event_name 'push')", "$ {{ 1 }}", "${{ 1 }}", "${{", "}}", "{{ 1 }}", "{ 1 }", "1 }", "a.b.c", "github.a.b.c.d", "git hub", "github github.ref"]) add(e);
250+
251+// ---- seeded random ----------------------------------------------------------
252+
253+function mulberry32(a) {
254+ return function () {
255+ a |= 0; a = (a + 0x6d2b79f5) | 0;
256+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
257+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
258+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
259+ };
260+}
261+const rnd = mulberry32(20260512);
262+const pick = a => a[Math.floor(rnd() * a.length)];
263+const lits = ["true", "false", "null", "0", "1", "2", "42", "-1", "1.5", "0.1", "NaN", "Infinity", "''", "'a'", "'A'", "'abc'", "'1'", "'0'", "'true'", "'x y'", "'it''s'", "'{0}'", "'[1,2]'", "'{\"a\":1}'", "'\u00df'", "'\u0131'", "'\u03a3'", "'\u{1F600}'"];
264+const paths = ["github", "github.event_name", "github.event.commits", "github.event.commits.*", "github.event.commits.*.id", "github.event.commits[0]", "github.event.commits[1].author", "github.event.commits[1].author.name", "github.event.labels.*.name", "github.event.nested.*.*", "github.event.nested[3]", "github.ref", "github.nope", "github.event.nope.deep",
265+ "env", "env.FOO", "env.NUM", "env.EMPTY", "matrix", "matrix.node", "matrix.node.*", "matrix.node[1]", "matrix.v", "matrix.flag", "matrix.nothing", "matrix.include.*.os", "matrix.obj", "inputs", "inputs.count", "inputs.deploy", "inputs.list.*", "inputs.empty", "steps.build.outputs.r", "steps.*.outputs", "steps['10']", "needs", "needs.*", "vars.A", "vars.B"];
266+function gen(d) {
267+ const r = rnd();
268+ if (d <= 0 || r < 0.3) return rnd() < 0.5 ? pick(lits) : pick(paths);
269+ if (r < 0.4) return pick(paths) + (rnd() < 0.5 ? `[${gen(d - 1)}]` : rnd() < 0.5 ? ".*" : "[*]");
270+ if (r < 0.5) return `!${gen(d - 1)}`;
271+ if (r < 0.58) return `(${gen(d - 1)})`;
272+ if (r < 0.7) return `${gen(d - 1)} ${pick(["==", "!=", "<", "<=", ">", ">="])} ${gen(d - 1)}`;
273+ if (r < 0.78) return `${gen(d - 1)} ${pick(["&&", "||"])} ${gen(d - 1)}`;
274+ const fn = pick([["contains", 2], ["startsWith", 2], ["endsWith", 2], ["format", 3], ["fromJSON", 1], ["toJSON", 1], ["join", 2], ["case", 3], ["hashFiles", 2], ["success", 0]]);
275+ const n = fn[0] === "format" ? 1 + Math.floor(rnd() * 3) : fn[0] === "case" ? pick([1, 3, 5]) : fn[0] === "join" ? 1 + Math.floor(rnd() * 2) : fn[1];
276+ const args = Array.from({ length: n }, () => gen(d - 1));
277+ if (fn[0] === "format" && rnd() < 0.7) args[0] = pick(["'{0}'", "'{0}{1}'", "'a{1}b{0}c'", "'{{{0}}}'", "'{0:x}'", "'{2}'"]);
278+ if (fn[0] === "case" && rnd() < 0.7) for (let i = 0; i + 1 < args.length; i += 2) args[i] = pick(["true", "false", "1 == 1", "!0", "github.event_name == 'push'"]);
279+ if (fn[0] === "fromJSON" && rnd() < 0.5) args[0] = pick(["'[1,2]'", "'{\"a\":{\"b\":1}}'", "'null'", "'\"x\"'", "'1e3'", "'{'", "''", "'[{\"a\":1},{\"a\":2}]'", "toJSON(" + gen(d - 1) + ")"]);
280+ return `${fn[0]}(${args.join(", ")})`;
281+}
282+const alphabet = ["(", ")", "[", "]", ".", ",", "!", "=", "&", "|", "<", ">", "*", "'", " ", "\n", "-", "+", "0", "1", "a", "github", "true", "null", "\u00e9", "\u{1F600}", "''", "&&", "||", "==", "!=", ">=", "<="];
283+function mutate(s) {
284+ const chars = Array.from(s);
285+ const k = 1 + Math.floor(rnd() * 3);
286+ for (let i = 0; i < k; i++) {
287+ const at = Math.floor(rnd() * (chars.length + 1));
288+ switch (Math.floor(rnd() * 3)) {
289+ case 0: chars.splice(at, 0, pick(alphabet)); break;
290+ case 1: chars.splice(at, 1); break;
291+ default: chars.splice(at, 1, pick(alphabet));
292+ }
293+ }
294+ return chars.join("");
295+}
296+for (let i = 0; i < 2500; i++) add(gen(1 + Math.floor(rnd() * 4)));
297+for (let i = 0; i < 1500; i++) add(mutate(gen(1 + Math.floor(rnd() * 3))));
298+// random doubles as literals
299+for (let i = 0; i < 400; i++) {
300+ const digits = Array.from({ length: 1 + Math.floor(rnd() * 20) }, () => Math.floor(rnd() * 10)).join("");
301+ const dot = Math.floor(rnd() * (digits.length + 1));
302+ let lit = digits.slice(0, dot) + (dot < digits.length || rnd() < 0.3 ? "." : "") + digits.slice(dot);
303+ if (lit === "." || lit === "") lit = "0.5";
304+ if (rnd() < 0.3) lit += "e" + (rnd() < 0.5 ? "-" : "") + Math.floor(rnd() * 30);
305+ add(`format('{0}', ${lit})`, `toJSON(${lit})`);
306+}
307+
308+const seen = new Set();
309+const cases = [];
310+for (const e of corpus) {
311+ if (seen.has(e)) continue;
312+ seen.add(e);
313+ cases.push(run(e));
314+}
315+const skipped = cases.filter(c => c.skip).length;
316+console.error(`cases=${cases.length} skipped(JS crash)=${skipped}`);
317+process.stdout.write(JSON.stringify({ reference: "@actions/expressions 0.3.61", contexts, cases: cases.filter(c => !c.skip) }) + "\n");
@@ -0,0 +1,317 @@
1+// SPDX-License-Identifier: MIT
2+//
3+// Generates testdata/oracle.json.gz: the results of the reference implementation
4+// (@actions/expressions 0.3.61, MIT, GitHub) on a corpus of expressions. The Go
5+// tests (oracle_test.go) replay the corpus and compare token by token what
6+// GitHub's JavaScript library does with what this package does.
7+//
8+// node gen/oracle.mjs /path/to/@actions/expressions/dist | gzip -9 -n > testdata/oracle.json.gz
9+//
10+// The corpus is hand-written edge cases plus a seeded pseudo-random walk over
11+// the grammar and over mutations of it, so the file is reproducible.
12+
13+import { pathToFileURL } from "node:url";
14+import path from "node:path";
15+
16+const dist = path.resolve(process.argv[2] ?? "node_modules/@actions/expressions/dist");
17+const ex = await import(pathToFileURL(path.join(dist, "index.js")).href);
18+const { Lexer, Parser, Evaluator, data } = ex;
19+
20+// ---- contexts -------------------------------------------------------------
21+
22+const contexts = {
23+ github: {
24+ event_name: "push",
25+ ref: "refs/heads/main",
26+ sha: "0123abc",
27+ actor: "Octocat",
28+ repository: "octo/Hello-World",
29+ run_number: 7,
30+ run_id: "1658821493",
31+ event: {
32+ number: 12,
33+ commits: [
34+ { id: "1", message: "fix: bug", author: { name: "Ann", email: "a@x" }, added: ["a.txt"] },
35+ { id: "2", message: "feat: thing", author: { name: "Bob" }, added: [] },
36+ { id: "3", message: "Chore", added: ["b", "c"] },
37+ ],
38+ head_commit: { message: "hello", timestamp: null },
39+ labels: [{ name: "bug" }, { name: "Docs" }, { name: "help wanted" }],
40+ pull_request: { draft: false, title: "A title", labels: [{ name: "x" }] },
41+ nested: [[1, 2], [3], [], [4, 5, 6]],
42+ },
43+ "weird-key": 1,
44+ "a b": "space",
45+ },
46+ env: { FOO: "bar", EMPTY: "", NUM: "42", TRUE: "true", JSONTEXT: '{"a":[1,2,3]}', Mixed: "MiXeD", Float: "1.50", Hex: "0x1F" },
47+ matrix: {
48+ os: "ubuntu",
49+ node: [14, 16, 18],
50+ include: [{ os: "windows", node: 20 }, { os: "mac", experimental: true }],
51+ v: 1.5,
52+ flag: true,
53+ nothing: null,
54+ ver: "3.10",
55+ big: 12345678901234567890,
56+ neg: -0.25,
57+ tiny: 1e-7,
58+ zero: 0,
59+ obj: { k: "v" },
60+ },
61+ inputs: { name: "World", count: 3, deploy: false, list: ["a", "b"], empty: {} },
62+ steps: { "10": { outputs: { r: "ten" } }, "2": { outputs: { r: "two" } }, build: { outputs: { r: "b", Other: "o" }, outcome: "success" } },
63+ needs: {},
64+ vars: { A: "1", B: "two" },
65+};
66+const ctxNames = Object.keys(contexts);
67+
68+const extFuncs = [
69+ { name: "success", description: "", minArgs: 0, maxArgs: 0, call: () => new data.BooleanData(true) },
70+ { name: "always", description: "", minArgs: 0, maxArgs: 0, call: () => new data.BooleanData(true) },
71+ {
72+ name: "hashFiles",
73+ description: "",
74+ minArgs: 1,
75+ maxArgs: 255,
76+ call: (...a) => new data.StringData("hash:" + a.map(x => x.coerceString()).join("|")),
77+ },
78+];
79+const extInfos = extFuncs.map(f => ({ name: f.name, description: f.description, minArgs: f.minArgs, maxArgs: f.maxArgs }));
80+const funcMap = new Map(extFuncs.map(f => [f.name.toLowerCase(), f]));
81+
82+const rootDict = new data.Dictionary(
83+ ...Object.keys(contexts).map(k => ({ key: k, value: JSON.parse(JSON.stringify(contexts[k]), data.reviver) }))
84+);
85+
86+// ---- running the reference -------------------------------------------------
87+
88+function kindOf(v) {
89+ switch (v.kind) {
90+ case data.Kind.Null: return "null";
91+ case data.Kind.Boolean: return "boolean";
92+ case data.Kind.Number: return "number";
93+ case data.Kind.String: return "string";
94+ case data.Kind.Array: return "array";
95+ case data.Kind.Dictionary: return "object";
96+ }
97+ return "?";
98+}
99+
100+function run(expr) {
101+ const out = { e: expr };
102+ let tokens;
103+ try {
104+ tokens = new Lexer(expr).lex().tokens;
105+ } catch (e) {
106+ if (!(e instanceof Error) || e instanceof TypeError || e instanceof RangeError) { out.skip = String(e); return out; }
107+ out.lexErr = e.message;
108+ return out;
109+ }
110+ out.tokens = tokens.map(t => [t.type, t.lexeme, t.range.start.line, t.range.start.column, t.range.end.line, t.range.end.column,
111+ t.type === 17 ? (Number.isNaN(t.value) ? "NaN" : String(t.value)) : t.type === 18 ? t.value : null]);
112+ let ast;
113+ try {
114+ ast = new Parser(tokens, ctxNames, extInfos).parse();
115+ } catch (e) {
116+ if (e instanceof TypeError || e instanceof RangeError) { out.skip = String(e); return out; }
117+ out.parseErr = e.message;
118+ if (e.pos) out.pos = [e.pos.line, e.pos.column];
119+ return out;
120+ }
121+ if (ast === undefined) { out.empty = true; return out; }
122+ let res;
123+ try {
124+ res = new Evaluator(ast, rootDict, funcMap).evaluate();
125+ } catch (e) {
126+ if (e instanceof TypeError || e instanceof RangeError) { out.skip = String(e); return out; }
127+ out.evalErr = e.message;
128+ return out;
129+ }
130+ if (res === undefined) { out.skip = "undefined result"; return out; }
131+ out.r = { k: kindOf(res), s: res.coerceString(), j: JSON.stringify(res, data.replacer, " ") };
132+ return out;
133+}
134+
135+// ---- corpus ----------------------------------------------------------------
136+
137+const corpus = [];
138+const add = (...xs) => corpus.push(...xs);
139+
140+// literals, numbers
141+add("true", "false", "null", "NaN", "Infinity", "-Infinity", "+Infinity", "1", "-1", "+1", "1.5", ".5", "-.5", "5.", "1e3", "1E-3", "1e21", "1e-7",
142+ "0x10", "0X1f", "0o17", "0b101", "-0x10", "0x", "0b2", "1_000", "1e", "1e+", "--1", "+-1", "-", "+", ".", "..", "1.2.3", "0.1", "0.30000000000000004",
143+ "100000000000000000000", "1000000000000000000000", "123456789012345678", "-0", "0", "00", "007", "1e308", "1e309", "-1e309", "4.9e-324",
144+ "0.000001", "0.0000001", "123456789.123456789", "0.1234567890123456789", "9.9999999999999995", "0.9999999999999999", "0.99999999999999999",
145+ "1.0000000000000002", "2.5", "0.5", "1.005", "1.45", "8.345", "0.000000000000005", "0.0000000000000005", "0.0000000000000004999",
146+ "1e15", "1e16", "123456789012345.6", "12345678901234567.5");
147+for (const n of ["0.1", "0.5", "1.5", "2.5", "1e21", "1e-7", "1e300", "-1e-7", "0.000001", "123456789012345678901", "5e-324", "1.7976931348623157e308",
148+ "0.30000000000000004", "1.005", "0.1234567890123456", "0.12345678901234567", "1234567.1234567890123456", "-0.0000000000000005", "0.0000000000000005",
149+ "NaN", "Infinity", "-Infinity", "100", "1e20", "123e-20", "99999999999999999999", "0.000000000000001", "0.0000000000000011", "4503599627370497.5",
150+ "0.45", "0.55", "-1.5", "1.0000000000000005", "1.0000000000000004", "10.000000000000005"]) {
151+ add(`format('{0}', ${n})`, `toJSON(${n})`, `${n} == '${n}'`, `'${n}' == ${n}`, `join(fromJSON('[${n}]'), ',')`);
152+}
153+
154+// strings
155+add("'a'", "''", "''''", "'it''s'", "'a''b''c'", "'''a'", "'a'''", "'a''''b'", "'unterminated", "'", "'a'b", "'a' 'b'", "'multi\nline'",
156+ "'\u00e9'", "'\u{1F600}'", "'\u{1F600}' == '\u{1F600}'", "\"dq\"", "'tab\there'");
157+
158+// whitespace & lexing oddities
159+add(" true ", "\ttrue\r\n", "\n\ntrue", "\u00a0true", "true\u00a0", "tr\u00a0ue", "\vtrue", "true\u2028", "true\ufeff", "a", "github", "GITHUB", "GitHub.Event_Name",
160+ "github .event_name", "github. event_name", "github.\nevent_name", "github\n.event_name", "github.event_name.", "github..event_name", ".github", "github.",
161+ "github[", "github[]", "github['event_name'", "github['event_name']]", "github[0]", "github[-1]", "github[1.5]", "github['0']", "github[true]", "github[null]",
162+ "github[nan]", "github[NaN]", "github[Infinity]", "github[github]", "github[github.event]", "github['EVENT_NAME']", "github[ 'event_name' ]",
163+ "=", "==", "===", "= =", "&", "&&", "&&&", "|", "||", "|||", "!", "!!", "!!!true", "!=", "!==", "!==true", "<", "<=", "<=>", ">", ">=", ">==",
164+ "1 = 1", "1 & 1", "1 | 1", "1 &&", "&& 1", "1 && && 1", "1 ||", "|| 1", "@", "#", "$", "1 + 1", "1 - 1", "1 * 1", "1 / 1", "a-b", "a_b", "a1", "1a", "_a", "-a", "a-",
165+ "github-", "github-x", "github.event-name", "github.event_name-x", "github.0", "github.1a", "github.-x", "github.true", "github.null", "github.NaN", "github.Infinity",
166+ "github.false", "env.TRUE", "env.true", "env.null", "(", ")", "()", "(())", "(1", "1)", "((1)", "(1))", "(1)(2)", "1 2", "1,2", ",", "1,", "[", "]", "[]", "[1]", "{}",
167+ "*", "*.a", "1.*", "'a'.b", "'a'[0]", "(github).event_name", "(github)['event_name']", "(github.event).number", "(1).a", "(1)[0]", "1[0]", "true.a", "null.a", "null[0]",
168+ "true[0]", "'abc'[0]", "'abc'.length", "(('a'))[0]", "toJSON(1)[0]", "fromJSON('[1,2]')[1]", "fromJSON('{\"a\":1}').a", "fromJSON('{\"a\":1}')['A']", "fromJSON('[[1]]')[0][0]");
169+
170+// operators, coercions
171+const vals = ["null", "true", "false", "0", "1", "-1", "0.5", "NaN", "Infinity", "''", "'0'", "'1'", "' 1 '", "'abc'", "'ABC'", "'a'", "'b'", "'true'", "'null'", "'NaN'", "'Infinity'", "'0x10'", "'1e2'", "'-1'", "'1.0'", "'\u00df'", "'SS'", "'ss'", "'\u0131'", "'I'", "'i'", "'\u0130'", "'\u03c3'", "'\u03c2'", "'\u03a3'", "'\ufb03'", "'\u212a'", "'k'", "'K'", "'\u{1F600}'", "'\ue000'", "'\u{10000}'", "github", "github.event.commits", "github.event.commits[0]", "env", "matrix.node", "matrix.obj", "inputs.empty", "fromJSON('[]')", "fromJSON('{}')", "github.event.commits.*", "github.event.nope.*", "matrix.nothing", "env.NUM", "env.EMPTY"];
172+for (const a of vals) {
173+ add(`!${a}`, `!!${a}`, `${a} || 'dflt'`, `${a} && 'then'`, `format('{0}', ${a})`, `toJSON(${a})`, `join(${a})`, `join(${a}, '-')`, `fromJSON(${a})`, `case(${a}, 'x', 'y', 'z')`, `case(${a} == ${a}, 1, 2, 3)`);
174+ for (const b of vals) {
175+ for (const op of ["==", "!=", "<", "<=", ">", ">="]) add(`${a} ${op} ${b}`);
176+ add(`contains(${a}, ${b})`, `startsWith(${a}, ${b})`, `endsWith(${a}, ${b})`);
177+ }
178+}
179+
180+// precedence and chains
181+add("true || false && false", "(true || false) && false", "false && true || true", "1 == 1 == true", "1 < 2 == true", "true == 1 < 2", "!1 == 2", "!(1 == 2)", "!1 < 2", "1 < !2", "!true == false",
182+ "1 || 2 || 3", "0 || '' || null", "0 || '' || 'x'", "1 && 2 && 3", "1 && 0 && 3", "null && 1", "null || null", "'a' && 'b' || 'c'", "'' && 'b' || 'c'", "a && b", "true && (false || true)",
183+ "1 == 1 && 2 == 2 || 3 == 4", "1 != 1 || 2 != 2", "github.event_name == 'push' && contains(github.ref, 'main')", "github.event_name != 'push' || !contains(github.ref, 'dev')",
184+ "contains(github.event.commits.*.message, 'fix: bug')", "contains(github.event.commits.*.message, 'FIX: BUG')", "contains(toJSON(github.event.commits), 'Bob')",
185+ "github.event.commits[0].author.name", "github.event.commits[1].author.email", "github.event.commits[2].author.name", "github.event.commits[3]", "github.event.commits[-1]", "github.event.commits['1'].id",
186+ "github.event.commits[1.9].id", "github.event.commits['1.9'].id", "github.event.commits[' 1 '].id", "github.event.commits[true]", "github.event.commits[false].id", "github.event.commits[null].id",
187+ "github.event.commits[1e0].id", "github.event.commits[0x1].id", "github.event.commits[Infinity]", "github.event.commits[-0].id", "github.event.commits[1e400]", "github.event.commits[1e20]",
188+ "github.event.commits.*", "github.event.commits.*.id", "github.event.commits.*.author", "github.event.commits.*.author.name", "github.event.commits.*.author.email", "github.event.commits.*.added",
189+ "github.event.commits.*.added.*", "github.event.commits.*.added[0]", "github.event.commits.*.added[1]", "github.event.commits.*.*", "github.event.commits[*].id", "github.event.commits[*]",
190+ "github.event.commits[*][*]", "github.event.commits.*[0]", "github.event.commits.*['id']", "github.event.commits.*.author.*", "github.*", "github.event.*", "github.*.number", "github.*.*", "github.event.*.*",
191+ "github.event.nested.*", "github.event.nested.*.*", "github.event.nested[*][0]", "github.event.nested.*[1]", "github.event.nested.*.*.*", "github.event.nested[3][1]", "github.event.nested[2][0]",
192+ "github.event.labels.*.name", "join(github.event.labels.*.name, ', ')", "join(github.event.labels.*.name)", "toJSON(github.event.labels.*.name)", "toJSON(github.event.labels.*)",
193+ "github.event.head_commit.*", "github.event.head_commit.message", "github.event.head_commit.timestamp", "github.event.head_commit.timestamp.x", "github.event.head_commit.timestamp.*",
194+ "github.event.pull_request.draft", "github.event.pull_request.draft == false", "!github.event.pull_request.draft", "github.event.pull_request.labels.*.name",
195+ "github['weird-key']", "github.weird-key", "github['a b']", "github.a b", "github.EVENT.NUMBER", "github.Event['Number']", "github.event.number == 12", "github.event.number == '12'", "github.event.number > 5",
196+ "github.event.number > '5'", "github.event.number > 'x'", "github.run_number", "github.run_id", "github.run_id == 1658821493", "github.run_id > 1658821492",
197+ "env.FOO", "env.foo", "env.Mixed == 'mixed'", "env.NUM == 42", "env.NUM > 41", "env.NUM + 1", "env.Float == 1.5", "env.Hex == 31", "env.TRUE == true", "env.EMPTY == null", "env.EMPTY == false", "env.EMPTY",
198+ "fromJSON(env.JSONTEXT).a[1]", "fromJSON(env.JSONTEXT).a.*", "toJSON(fromJSON(env.JSONTEXT))",
199+ "matrix.os", "matrix.node", "matrix.node[0]", "matrix.node.*", "matrix.include.*.os", "matrix.include[0].node", "matrix.include[1].experimental", "matrix.v", "matrix.flag", "matrix.nothing", "matrix.neg", "matrix.tiny",
200+ "matrix.big", "matrix.big == 12345678901234567890", "format('{0}', matrix.big)", "format('{0} {1}', matrix.tiny, matrix.neg)", "matrix.ver", "matrix.ver == 3.1", "matrix.ver == '3.10'", "matrix.zero || 'z'",
201+ "matrix.obj", "matrix.obj == matrix.obj", "matrix.obj != matrix.obj", "matrix.node == matrix.node", "github.event.commits == github.event.commits", "github.event.commits.* == github.event.commits.*",
202+ "matrix.obj.k", "matrix.obj['k']", "matrix.obj[0]", "matrix.obj.*", "matrix.node['0']", "matrix.node[0] == 14", "matrix.node[0] < matrix.node[1]", "contains(matrix.node, 16)", "contains(matrix.node, '16')", "contains(matrix.node, 17)",
203+ "contains(matrix.include.*.os, 'MAC')", "inputs.name", "inputs.count", "inputs.count == 3", "inputs.deploy", "inputs.deploy == false", "!inputs.deploy", "inputs.list", "inputs.list.*", "inputs.empty", "inputs.empty.*", "inputs.empty.a",
204+ "inputs.missing", "inputs.missing.deeper", "inputs.missing[0]", "inputs.missing.*", "inputs.missing.*.x", "inputs['missing']['x']", "steps.build.outputs.r", "steps.BUILD.OUTPUTS.OTHER", "steps.build.outcome == 'success'", "steps.build.outputs.*",
205+ "steps.10.outputs.r", "steps['10'].outputs.r", "steps[10].outputs.r", "steps[2].outputs.r", "steps.*", "toJSON(steps)", "toJSON(steps.*.outputs)", "steps.*.outputs.r", "needs", "needs.x", "needs.x.result", "needs.*", "toJSON(needs)", "toJSON(needs.*)",
206+ "vars.A", "vars.B", "vars.A == 1", "vars.C || 'default'", "toJSON(vars)", "toJSON(github.event.commits[0])", "toJSON(github.event)", "toJSON(contains)", "toJSON(github)", "toJSON(matrix)", "toJSON(inputs)", "toJSON(env)",
207+ "toJSON(github.event.commits.*.added)", "toJSON(github.event.nested)", "toJSON(github.event.nested.*)", "toJSON(github.event.nested.*.*)", "toJSON(matrix.big)", "toJSON(matrix.tiny)", "toJSON(matrix.neg)", "toJSON(matrix.nothing)", "toJSON(inputs.empty)", "toJSON(inputs.list)");
208+
209+// functions: arity and behaviours
210+add("contains()", "contains(1)", "contains(1,2)", "contains(1,2,3)", "contains('a','b',)", "contains(,)", "contains('abc','B')", "contains('abc','')", "contains('', '')", "contains('', 'a')", "contains(null, '')", "contains(null, null)", "contains(1234, 23)", "contains(true, 'RU')", "contains(github, 'a')",
211+ "contains('a', github)", "contains(github.event.commits, 'x')", "contains('\u0130', 'i')", "contains('I', '\u0131')", "contains('\u00df', 'ss')", "contains('SS', '\u00df')", "contains('\u03a3', '\u03c3')", "contains('\u03c2', '\u03c3')", "contains('A\u03a3', 'a\u03c2')", "contains('\u212a', 'k')",
212+ "contains(fromJSON('[1,\"2\",null]'), null)", "contains(fromJSON('[1,\"2\",null]'), '')", "contains(fromJSON('[1,\"2\",null]'), 2)", "contains(fromJSON('[]'), null)", "contains(fromJSON('[[1]]'), fromJSON('[1]'))", "contains(github.event.commits.*.id, 2)", "contains(github.event.commits.*.id, '2')",
213+ "startsWith()", "startsWith('a')", "startsWith('abc','AB')", "startsWith('abc','')", "startsWith('', '')", "startsWith(null, '')", "startsWith(123, 12)", "startsWith(github, 'a')", "startsWith('\u0131', 'I')", "startsWith('\u0131', '\u0131')", "startsWith('i', '\u0131')", "startsWith('\u00df', 'ss')", "startsWith('SS', '\u00df')",
214+ "endsWith('abc','BC')", "endsWith('abc','')", "endsWith(null, 'a')", "endsWith(1.50, '5')", "endsWith(1.50, 5)", "endsWith('\u00df', 'S')", "endsWith('abc', 'abcd')",
215+ "format()", "format('a')", "format('')", "format(null)", "format(1)", "format(true)", "format('{0}')", "format('{0}', 1)", "format('{0}', 1, 2)", "format('{1}', 1)", "format('{1}', 1, 2)", "format('{0}{0}', 'x')", "format('{0}{1}{0}', 'a', 'b')", "format('{{')", "format('}}')", "format('{{}}')", "format('{{0}}', 1)",
216+ "format('{{{0}}}', 1)", "format('{', 1)", "format('}', 1)", "format('{}', 1)", "format('{ 0}', 1)", "format('{0 }', 1)", "format('{0:x}', 1)", "format('{0,5}', 1)", "format('{0 anything}', 1)", "format('{00}', 1)", "format('{01}', 1, 2)", "format('{-1}', 1)", "format('{a}', 1)", "format('{0', 1)", "format('0}', 1)", "format('{{0}', 1)", "format('{0}}', 1)",
217+ "format('{0}}}', 1)", "format('a{0}b{1}c', 1, 2)", "format('{0}', null)", "format('{0}', true)", "format('{0}', github)", "format('{0}', github.event.commits)", "format('{0}', matrix.node.*)", "format('{12345678901234567890123}', 1)", "format('{99999999999999999999999999999999999}', 1)", "format('{0}\n{0}', 'a')", "format('x}', 1)", "format('}{', 1)", "format('{{{{', 1)", "format('{}}', 1)", "format('{0}{{', 1)", "format('{{{0}', 1)",
218+ "format('{0}', 'a', 'b', 'c')", "format('{3}', 0, 1, 2, 3)", "format('{2}', 0, 1, 2, 3)", "format('{255}', 1)", "format('\u{1F600}{0}\u{1F600}', 'x')", "format('{0}', '\u{1F600}')", "format('{ {0}', 1)", "format(' {0} ', 1)",
219+ "fromJSON()", "fromJSON('1','2')", "fromJSON('')", "fromJSON(' ')", "fromJSON('\n')", "fromJSON(null)", "fromJSON('null')", "fromJSON('true')", "fromJSON('false')", "fromJSON('1')", "fromJSON('-1.5e2')", "fromJSON('\"s\"')", "fromJSON('[1,2,3]')", "fromJSON('{\"a\":1}')", "fromJSON('{\"a\":1,\"A\":2}')", "fromJSON('{\"a\":1,\"a\":2}')", "toJSON(fromJSON('{\"a\":1,\"A\":2}'))", "toJSON(fromJSON('{\"a\":1,\"a\":2,\"b\":3}'))",
220+ "toJSON(fromJSON('{\"b\":1,\"2\":2,\"1\":3,\"a\":4,\"01\":5,\"4294967294\":6,\"4294967295\":7,\"-1\":8}'))", "toJSON(fromJSON('{\"\":1}'))", "toJSON(fromJSON('{\"a\":{\"b\":{\"c\":[]}}}'))", "fromJSON('{')", "fromJSON('{\"a\"')", "fromJSON('[1,]')", "fromJSON('[1 2]')", "fromJSON('{a:1}')", "fromJSON(\"{'a':1}\")", "fromJSON('01')", "fromJSON('1.')", "fromJSON('.5')", "fromJSON('+1')", "fromJSON('NaN')", "fromJSON('Infinity')", "fromJSON('undefined')", "fromJSON('nul')", "fromJSON('truee')",
221+ "fromJSON('1 2')", "fromJSON(' 1 ')", "fromJSON('\"\\u0041\"')", "fromJSON('\"\\ud83d\\ude00\"')", "fromJSON('\"\\x\"')", "fromJSON('\"a\nb\"')", "fromJSON('\"\\/\"')", "fromJSON('1e999')", "fromJSON('-1e999')", "fromJSON('-0')", "toJSON(fromJSON('-0'))", "toJSON(fromJSON('1e999'))", "toJSON(fromJSON('1E2'))", "toJSON(fromJSON('0.1e1'))", "toJSON(fromJSON('123456789012345678901234567890'))",
222+ "toJSON(fromJSON('[1e21,1e-7,0.000001,100,1.5,-2.5e-10]'))", "toJSON(fromJSON('\"\\u0000\\u001f\\u007f\\u2028\\\"\\\\\"'))", "toJSON('\u0000')", "toJSON('\u001f\u007f\u0080\u2028\u2029\ufeff')", "toJSON('\"\\')", "toJSON('a\nb\tc\rd\be\ff')", "toJSON('\u00e9\u00df\u{1F600}')", "toJSON(fromJSON('[]'))", "toJSON(fromJSON('{}'))", "toJSON(fromJSON('[[],{}]'))", "toJSON(fromJSON('{\"a\":[],\"b\":{}}'))", "toJSON(fromJSON('[null]'))",
223+ "toJSON()", "toJSON(1,2)", "toJSON(null)", "toJSON(true)", "toJSON(1)", "toJSON(NaN)", "toJSON(Infinity)", "toJSON(-Infinity)", "toJSON(-0)", "toJSON('x')", "toJSON('')", "toJSON(1e21)", "toJSON(1e-7)", "toJSON(0.1)", "toJSON(123456789012345680000)",
224+ "join()", "join('a')", "join('a','b')", "join(null)", "join(1.50)", "join(true, 'x')", "join(github)", "join(github, 'x')", "join(fromJSON('[]'))", "join(fromJSON('[1,2,3]'))", "join(fromJSON('[1,2,3]'), '')", "join(fromJSON('[1,2,3]'), ', ')", "join(fromJSON('[1,2,3]'), 1)", "join(fromJSON('[1,2,3]'), null)", "join(fromJSON('[1,2,3]'), true)", "join(fromJSON('[1,2,3]'), github)", "join(fromJSON('[1,2,3]'), fromJSON('[]'))", "join(fromJSON('[1,2,3]'), NaN)",
225+ "join(fromJSON('[null,true,\"a\",1.5,[1],{}]'))", "join(matrix.node, '.')", "join(matrix.node.*, '.')", "join(github.event.commits.*.id, '/')", "join(1,2,3)", "join(fromJSON('[\"a\",\"b\"]'), '\u{1F600}')",
226+ "case()", "case(true)", "case(true, 1)", "case(true, 1, 2)", "case(false, 1, 2)", "case(true, 1, false, 2, 3)", "case(false, 1, true, 2, 3)", "case(false, 1, false, 2, 3)", "case(false, 1, false, 2, 3, 4)", "case(false, 1, false, 2, 3, 4, 5)", "case(1, 2, 3)", "case('true', 2, 3)", "case(null, 2, 3)", "case(false, 2, 3, 4)", "case(true, 2, 1, 3)", "case(true, 2, null, 3)", "case(false, 1, 'x', 3)",
227+ "case(true, github.event_name, 'other')", "case(github.event_name == 'push', 'a', github.event_name == 'pr', 'b', 'c')", "CASE(true, 1, 2)", "case(true, matrix.node, 2)", "toJSON(case(true, matrix.node.*, 2))", "case(1 == 1, 'a', 'b')", "case(!0, 'a', 'b')", "case(fromJSON('true'), 'a', 'b')",
228+ "CONTAINS('a','A')", "Contains('a','A')", "STARTSWITH('a','A')", "endswith('a','A')", "FORMAT('{0}',1)", "fromjson('1')", "FROMJSON('1')", "tojson(1)", "TOJSON(1)", "JOIN(fromJSON('[1]'))", "unknown()", "unknown(1)", "Constantine()", "notafunction", "success", "success()", "success(1)", "always()", "ALWAYS()", "hashFiles()", "hashFiles('a')", "hashFiles('a','b')", "hashFiles('**/*.go', '!x')", "HASHFILES('a')", "failure()", "cancelled()",
229+ "success() && hashFiles('a') != ''", "contains(github.ref, 'main') && success()", "always() || false", "hashFiles(github.sha, null, 1.5)", "hashFiles(1,2,3,4,5,6,7,8,9,10)", "format()", "format(1,2,3,4)");
230+add("hashFiles(" + Array.from({ length: 255 }, (_, i) => `'${i}'`).join(",") + ")", "hashFiles(" + Array.from({ length: 256 }, (_, i) => `'${i}'`).join(",") + ")");
231+add("format('{0}'" + ", 1".repeat(254) + ")", "format('{0}'" + ", 1".repeat(255) + ")", "case(" + Array.from({ length: 101 }, (_, i) => i < 100 ? (i % 2 == 0 ? "false" : "1") : "'end'").join(",") + ")");
232+add("contains('a', 'b',)", "contains(1,)", "contains(,1)", "contains('a' 'b')", "contains('a';'b')", "contains(('a'), ('b'))", "contains(contains('a','a'), true)", "format(format('{{0}}'), 5)", "toJSON(toJSON(1))", "fromJSON(toJSON(github.event.commits[0])).author.name", "fromJSON(fromJSON('\"[1]\"'))[0]", "fromJSON('[1,2]')[fromJSON('1')]",
233+ "join(fromJSON(toJSON(github.event.labels.*.name)), '+')", "toJSON(fromJSON(toJSON(github.event.commits)))", "format('{0}', toJSON(null))", "fromJSON(format('[{0},{1}]', 1, 2))[1]", "fromJSON('[{\"a\":1},{\"a\":2},{\"b\":3}]').*.a", "toJSON(fromJSON('[{\"a\":1},{\"a\":2},{\"b\":3}]').*.a)", "toJSON(fromJSON('[{\"a\":[1,2]},{\"a\":[3]}]').*.a.*)", "toJSON(fromJSON('[{\"a\":[1,2]},{\"a\":[3]}]').*.a[0])", "toJSON(fromJSON('[[1,2],[3]]').*[0])", "toJSON(fromJSON('[[1,2],[3]]')[*][1])",
234+ "toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":{\"x\":2}}').*.x)", "toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":{\"x\":2}}')[*].x)", "toJSON(fromJSON('{\"a\":{\"x\":1},\"b\":2}').*.x)", "toJSON(fromJSON('[1,2]').*.x)", "toJSON(fromJSON('1').*)", "toJSON(null.*)", "toJSON('abc'.*)", "toJSON(fromJSON('[]').*.a)", "toJSON(fromJSON('[]').*)", "toJSON(fromJSON('{}').*)");
235+
236+// depth and length limits
237+const nest = (n, open, body, close) => open.repeat(n) + body + close.repeat(n);
238+for (const n of [1, 10, 24, 25, 26, 48, 49, 50, 51, 52, 100, 200]) {
239+ add(nest(n, "(", "1", ")"), "!".repeat(n) + "true", nest(n, "format(", "'x'", ")"), "github" + ".event".repeat(n), "github" + "[0]".repeat(n), "github" + "['event']".repeat(n),
240+ "1" + " || 1".repeat(n), "1" + " && 1".repeat(n), "1" + " == 1".repeat(n), nest(n, "(1 || ", "1", ")"), nest(n, "!(", "1", ")"), nest(n, "(1 && ", "1", ")"), nest(n, "github[", "0", "]"), nest(n, "contains(1, ", "1", ")"));
241+}
242+for (const n of [20990, 21000, 21001, 22000]) {
243+ add("'" + "a".repeat(n - 2) + "'", " ".repeat(n) + "1", "1" + " ".repeat(n - 1), "'" + "\u{1F600}".repeat(Math.floor((n - 2) / 2)) + "'", "'" + "\u{1F600}".repeat(Math.floor((n - 3) / 2)) + "'");
244+}
245+
246+// positions and error messages
247+for (const e of ["1 +", "a b", "github github", "github.event_name ==", "== 1", "(github.event_name", "github.event_name)", "contains('a',", "contains('a'", "contains('a' 'b')", "contains(", "github[0", "github[0)",
248+ "1 ==\n 2 ===\n 3", "true &&\n false ||\n nope", "\n\n bogus", "'a'\n 'b'\n zzz", "true\n&\nfalse", "foo(", "foo", "github.event_name\n .\n ", " ) ", "1 !", "1 !!", "1 ! 1", "!", "! ", "!(", "!)", "(!)", "[1]", "github.[0]", "github.*.", "github.*.*.", "github[*", "github[*]]", "github[* ]", "github[1 2]", "github[,]",
249+ "1 && ( 2 ||", "1\t2", "x\ty", "github\r\n.event", "a\u00a0b", "'x' =", "'x' = 'y'", "!= 1", ">", "<<", ">>", "1 <> 2", "1 >< 2", "1 =< 2", "1 => 2", "contains(github.event_name, 'push'", "contains(github.event_name 'push')", "$ {{ 1 }}", "${{ 1 }}", "${{", "}}", "{{ 1 }}", "{ 1 }", "1 }", "a.b.c", "github.a.b.c.d", "git hub", "github github.ref"]) add(e);
250+
251+// ---- seeded random ----------------------------------------------------------
252+
253+function mulberry32(a) {
254+ return function () {
255+ a |= 0; a = (a + 0x6d2b79f5) | 0;
256+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
257+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
258+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
259+ };
260+}
261+const rnd = mulberry32(20260512);
262+const pick = a => a[Math.floor(rnd() * a.length)];
263+const lits = ["true", "false", "null", "0", "1", "2", "42", "-1", "1.5", "0.1", "NaN", "Infinity", "''", "'a'", "'A'", "'abc'", "'1'", "'0'", "'true'", "'x y'", "'it''s'", "'{0}'", "'[1,2]'", "'{\"a\":1}'", "'\u00df'", "'\u0131'", "'\u03a3'", "'\u{1F600}'"];
264+const paths = ["github", "github.event_name", "github.event.commits", "github.event.commits.*", "github.event.commits.*.id", "github.event.commits[0]", "github.event.commits[1].author", "github.event.commits[1].author.name", "github.event.labels.*.name", "github.event.nested.*.*", "github.event.nested[3]", "github.ref", "github.nope", "github.event.nope.deep",
265+ "env", "env.FOO", "env.NUM", "env.EMPTY", "matrix", "matrix.node", "matrix.node.*", "matrix.node[1]", "matrix.v", "matrix.flag", "matrix.nothing", "matrix.include.*.os", "matrix.obj", "inputs", "inputs.count", "inputs.deploy", "inputs.list.*", "inputs.empty", "steps.build.outputs.r", "steps.*.outputs", "steps['10']", "needs", "needs.*", "vars.A", "vars.B"];
266+function gen(d) {
267+ const r = rnd();
268+ if (d <= 0 || r < 0.3) return rnd() < 0.5 ? pick(lits) : pick(paths);
269+ if (r < 0.4) return pick(paths) + (rnd() < 0.5 ? `[${gen(d - 1)}]` : rnd() < 0.5 ? ".*" : "[*]");
270+ if (r < 0.5) return `!${gen(d - 1)}`;
271+ if (r < 0.58) return `(${gen(d - 1)})`;
272+ if (r < 0.7) return `${gen(d - 1)} ${pick(["==", "!=", "<", "<=", ">", ">="])} ${gen(d - 1)}`;
273+ if (r < 0.78) return `${gen(d - 1)} ${pick(["&&", "||"])} ${gen(d - 1)}`;
274+ const fn = pick([["contains", 2], ["startsWith", 2], ["endsWith", 2], ["format", 3], ["fromJSON", 1], ["toJSON", 1], ["join", 2], ["case", 3], ["hashFiles", 2], ["success", 0]]);
275+ const n = fn[0] === "format" ? 1 + Math.floor(rnd() * 3) : fn[0] === "case" ? pick([1, 3, 5]) : fn[0] === "join" ? 1 + Math.floor(rnd() * 2) : fn[1];
276+ const args = Array.from({ length: n }, () => gen(d - 1));
277+ if (fn[0] === "format" && rnd() < 0.7) args[0] = pick(["'{0}'", "'{0}{1}'", "'a{1}b{0}c'", "'{{{0}}}'", "'{0:x}'", "'{2}'"]);
278+ if (fn[0] === "case" && rnd() < 0.7) for (let i = 0; i + 1 < args.length; i += 2) args[i] = pick(["true", "false", "1 == 1", "!0", "github.event_name == 'push'"]);
279+ if (fn[0] === "fromJSON" && rnd() < 0.5) args[0] = pick(["'[1,2]'", "'{\"a\":{\"b\":1}}'", "'null'", "'\"x\"'", "'1e3'", "'{'", "''", "'[{\"a\":1},{\"a\":2}]'", "toJSON(" + gen(d - 1) + ")"]);
280+ return `${fn[0]}(${args.join(", ")})`;
281+}
282+const alphabet = ["(", ")", "[", "]", ".", ",", "!", "=", "&", "|", "<", ">", "*", "'", " ", "\n", "-", "+", "0", "1", "a", "github", "true", "null", "\u00e9", "\u{1F600}", "''", "&&", "||", "==", "!=", ">=", "<="];
283+function mutate(s) {
284+ const chars = Array.from(s);
285+ const k = 1 + Math.floor(rnd() * 3);
286+ for (let i = 0; i < k; i++) {
287+ const at = Math.floor(rnd() * (chars.length + 1));
288+ switch (Math.floor(rnd() * 3)) {
289+ case 0: chars.splice(at, 0, pick(alphabet)); break;
290+ case 1: chars.splice(at, 1); break;
291+ default: chars.splice(at, 1, pick(alphabet));
292+ }
293+ }
294+ return chars.join("");
295+}
296+for (let i = 0; i < 2500; i++) add(gen(1 + Math.floor(rnd() * 4)));
297+for (let i = 0; i < 1500; i++) add(mutate(gen(1 + Math.floor(rnd() * 3))));
298+// random doubles as literals
299+for (let i = 0; i < 400; i++) {
300+ const digits = Array.from({ length: 1 + Math.floor(rnd() * 20) }, () => Math.floor(rnd() * 10)).join("");
301+ const dot = Math.floor(rnd() * (digits.length + 1));
302+ let lit = digits.slice(0, dot) + (dot < digits.length || rnd() < 0.3 ? "." : "") + digits.slice(dot);
303+ if (lit === "." || lit === "") lit = "0.5";
304+ if (rnd() < 0.3) lit += "e" + (rnd() < 0.5 ? "-" : "") + Math.floor(rnd() * 30);
305+ add(`format('{0}', ${lit})`, `toJSON(${lit})`);
306+}
307+
308+const seen = new Set();
309+const cases = [];
310+for (const e of corpus) {
311+ if (seen.has(e)) continue;
312+ seen.add(e);
313+ cases.push(run(e));
314+}
315+const skipped = cases.filter(c => c.skip).length;
316+console.error(`cases=${cases.length} skipped(JS crash)=${skipped}`);
317+process.stdout.write(JSON.stringify({ reference: "@actions/expressions 0.3.61", contexts, cases: cases.filter(c => !c.skip) }) + "\n");
A expr/golden_test.go +48 -0
@@ -0,0 +1,48 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+// TestToJSONGolden checks toJSON(fromJSON(x)) against the output of the
13+// reference implementation for each testdata/tojson/NAME.json. The .golden
14+// files are generated by gen/golden.mjs.
15+func TestToJSONGolden(t *testing.T) {
16+ inputs, err := filepath.Glob("testdata/tojson/*.json")
17+ if err != nil || len(inputs) < 30 {
18+ t.Fatalf("golden inputs: %v (%d found)", err, len(inputs))
19+ }
20+ for _, in := range inputs {
21+ name := strings.TrimSuffix(filepath.Base(in), ".json")
22+ t.Run(name, func(t *testing.T) {
23+ text, err := os.ReadFile(in)
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ want, err := os.ReadFile(strings.TrimSuffix(in, ".json") + ".golden")
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ v, err := FromJSON(text)
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ if got := ToJSON(v); got != string(want) {
36+ t.Errorf("toJSON mismatch\n got: %q\nwant: %q", got, want)
37+ }
38+ // The golden output is itself JSON that must round-trip.
39+ v2, err := FromJSON(want)
40+ if err != nil {
41+ t.Fatalf("golden output does not parse: %v", err)
42+ }
43+ if got := ToJSON(v2); got != string(want) {
44+ t.Errorf("toJSON is not stable\n got: %q\nwant: %q", got, want)
45+ }
46+ })
47+ }
48+}
@@ -0,0 +1,48 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+// TestToJSONGolden checks toJSON(fromJSON(x)) against the output of the
13+// reference implementation for each testdata/tojson/NAME.json. The .golden
14+// files are generated by gen/golden.mjs.
15+func TestToJSONGolden(t *testing.T) {
16+ inputs, err := filepath.Glob("testdata/tojson/*.json")
17+ if err != nil || len(inputs) < 30 {
18+ t.Fatalf("golden inputs: %v (%d found)", err, len(inputs))
19+ }
20+ for _, in := range inputs {
21+ name := strings.TrimSuffix(filepath.Base(in), ".json")
22+ t.Run(name, func(t *testing.T) {
23+ text, err := os.ReadFile(in)
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ want, err := os.ReadFile(strings.TrimSuffix(in, ".json") + ".golden")
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ v, err := FromJSON(text)
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ if got := ToJSON(v); got != string(want) {
36+ t.Errorf("toJSON mismatch\n got: %q\nwant: %q", got, want)
37+ }
38+ // The golden output is itself JSON that must round-trip.
39+ v2, err := FromJSON(want)
40+ if err != nil {
41+ t.Fatalf("golden output does not parse: %v", err)
42+ }
43+ if got := ToJSON(v2); got != string(want) {
44+ t.Errorf("toJSON is not stable\n got: %q\nwant: %q", got, want)
45+ }
46+ })
47+ }
48+}
A expr/json_test.go +266 -0
@@ -0,0 +1,266 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
6+ "math"
7+ "strings"
8+ "testing"
9+)
10+
11+func TestFromJSONValid(t *testing.T) {
12+ tests := []struct {
13+ in string
14+ want string // ToJSON of the result
15+ }{
16+ {"null", "null"},
17+ {" true ", "true"},
18+ {"\t\r\n false \n", "false"},
19+ {"0", "0"},
20+ {"-0", "0"},
21+ {"-12.5e1", "-125"},
22+ {"1E2", "100"},
23+ {"0.1e1", "1"},
24+ {`"a\u00e9\n"`, `"aé\n"`},
25+ {`"\ud83d\ude00"`, `"😀"`},
26+ {`"\ud800"`, `"\ud800"`},
27+ {`"\udc00x"`, `"\udc00x"`},
28+ {`"\ud800\u0041"`, `"\ud800A"`},
29+ {`"\/"`, `"/"`},
30+ {`[]`, `[]`},
31+ {`{}`, `{}`},
32+ {`[ 1 , [ ] , { } ]`, "[\n 1,\n [],\n {}\n]"},
33+ {`{"a":1,"A":2}`, "{\n \"a\": 2\n}"}, // see below: the last value of an exact duplicate wins, case variants collapse to the first
34+ {`{"a":1,"a":2}`, "{\n \"a\": 2\n}"},
35+ {`{"b":1,"1":2,"a":3,"0":4}`, "{\n \"0\": 4,\n \"1\": 2,\n \"b\": 1,\n \"a\": 3\n}"},
36+ }
37+ for _, tc := range tests {
38+ if tc.in == `{"a":1,"A":2}` {
39+ // "a" and "A" are distinct JSON keys but the same dictionary key:
40+ // the first one wins.
41+ tc.want = "{\n \"a\": 1\n}"
42+ }
43+ v, err := FromJSON([]byte(tc.in))
44+ if err != nil {
45+ t.Errorf("FromJSON(%q): %v", tc.in, err)
46+ continue
47+ }
48+ if got := ToJSON(v); got != tc.want {
49+ t.Errorf("FromJSON(%q) -> %q, want %q", tc.in, got, tc.want)
50+ }
51+ }
52+}
53+
54+func TestFromJSONInvalid(t *testing.T) {
55+ for _, in := range []string{
56+ "", " ", "{", "}", "[", "]", `{"a"`, `{"a":`, `{"a":1`, `{"a":1,}`, `{,}`, `{a:1}`, `{'a':1}`, `{"a" 1}`, `[1,]`, `[,1]`, `[1 2]`,
57+ "01", "1.", ".5", "+1", "-", "1e", "1e+", "--1", "0x10", "NaN", "Infinity", "-Infinity", "undefined", "nul", "truee", "tru", "True",
58+ `"abc`, `"a` + "\n" + `b"`, "\"\x01\"", `"\x"`, `"\u12"`, `"\u12g4"`, `"\`, "1 2", "[] []", "{} x", `"a""b"`, "\x00", "\u00a01", "1\u00a0", "\ufeff1",
59+ } {
60+ if v, err := FromJSON([]byte(in)); err == nil {
61+ t.Errorf("FromJSON(%q) = %v, want error", in, ToJSON(v))
62+ }
63+ }
64+}
65+
66+func TestFromJSONNumbers(t *testing.T) {
67+ for in, want := range map[string]float64{
68+ "1e999": math.Inf(1), "-1e999": math.Inf(-1), "1e-999": 0, "123456789012345678901234567890": 1.2345678901234568e29, "5e-324": 5e-324,
69+ } {
70+ v, err := FromJSON([]byte(in))
71+ if err != nil {
72+ t.Errorf("FromJSON(%q): %v", in, err)
73+ continue
74+ }
75+ if got := float64(v.(Number)); got != want {
76+ t.Errorf("FromJSON(%q) = %v, want %v", in, got, want)
77+ }
78+ }
79+ v, _ := FromJSON([]byte("-0"))
80+ if !math.Signbit(float64(v.(Number))) {
81+ t.Error("-0 lost its sign")
82+ }
83+}
84+
85+func TestFromJSONInvalidUTF8(t *testing.T) {
86+ v, err := FromJSON([]byte("\"a\xffb\""))
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ if s := string(v.(String)); s != "a\ufffdb" {
91+ t.Errorf("got %q", s)
92+ }
93+}
94+
95+func TestFromJSONDeepNesting(t *testing.T) {
96+ deep := strings.Repeat("[", 5000) + strings.Repeat("]", 5000)
97+ if _, err := FromJSON([]byte(deep)); err != nil {
98+ t.Errorf("depth 5000: %v", err)
99+ }
100+ tooDeep := strings.Repeat("[", maxJSONDepth+10) + strings.Repeat("]", maxJSONDepth+10)
101+ if _, err := FromJSON([]byte(tooDeep)); err == nil {
102+ t.Error("absurd depth accepted")
103+ }
104+}
105+
106+func TestToJSONFormatting(t *testing.T) {
107+ d := NewDictionary()
108+ d.Add("name", String("x"))
109+ d.Add("list", NewArray(Number(1), Null{}, Boolean(true), NewArray(), NewDictionary()))
110+ inner := NewDictionary()
111+ inner.Add("k", String("v"))
112+ d.Add("obj", inner)
113+ want := `{
114+ "name": "x",
115+ "list": [
116+ 1,
117+ null,
118+ true,
119+ [],
120+ {}
121+ ],
122+ "obj": {
123+ "k": "v"
124+ }
125+}`
126+ if got := ToJSON(d); got != want {
127+ t.Errorf("got\n%s\nwant\n%s", got, want)
128+ }
129+ tests := []struct {
130+ v Value
131+ want string
132+ }{
133+ {nil, "null"},
134+ {Null{}, "null"},
135+ {Number(math.NaN()), "null"},
136+ {Number(math.Inf(1)), "null"},
137+ {Number(math.Inf(-1)), "null"},
138+ {Number(math.Copysign(0, -1)), "0"},
139+ {Number(1e21), "1e+21"},
140+ {Number(1e-7), "1e-7"},
141+ {Number(123456789), "123456789"},
142+ {String("\u0000\u001f\u007f\u0080\u2028"), `"\u0000\u001f` + "\u007f\u0080\u2028" + `"`},
143+ {String("\"\\\b\f\n\r\t"), `"\"\\\b\f\n\r\t"`},
144+ {String("</script>&'"), `"</script>&'"`},
145+ {String("\xff"), "\"\ufffd\""},
146+ {(*Array)(nil), "null"},
147+ {(*Dictionary)(nil), "null"},
148+ {NewArray(nil), "[\n null\n]"},
149+ }
150+ for _, tc := range tests {
151+ if got := ToJSON(tc.v); got != tc.want {
152+ t.Errorf("ToJSON(%#v) = %q, want %q", tc.v, got, tc.want)
153+ }
154+ }
155+}
156+
157+func TestDictionary(t *testing.T) {
158+ d := NewDictionary()
159+ if !d.Add("Name", String("first")) {
160+ t.Fatal("Add failed")
161+ }
162+ if d.Add("NAME", String("second")) {
163+ t.Error("case variant was added")
164+ }
165+ d.Add("other", Number(1))
166+ if v, ok := d.Get("nAmE"); !ok || v.(String) != "first" {
167+ t.Errorf("Get = %v, %v", v, ok)
168+ }
169+ if _, ok := d.Get("missing"); ok {
170+ t.Error("Get of missing key succeeded")
171+ }
172+ if got := strings.Join(d.Keys(), ","); got != "Name,other" {
173+ t.Errorf("Keys = %s", got)
174+ }
175+ if d.Len() != 2 || len(d.Values()) != 2 || len(d.Pairs()) != 2 || d.Pairs()[0].Key != "Name" {
176+ t.Errorf("Len/Values/Pairs inconsistent: %d", d.Len())
177+ }
178+ // Non-ASCII keys fold like JavaScript's toLowerCase.
179+ d.Add("ΑΣ", String("sigma"))
180+ if _, ok := d.Get("ας"); !ok {
181+ t.Error("final sigma key not found")
182+ }
183+ var zero Dictionary
184+ zero.Add("a", Null{})
185+ if _, ok := zero.Get("A"); !ok {
186+ t.Error("zero Dictionary is not usable")
187+ }
188+ var nilDict *Dictionary
189+ if _, ok := nilDict.Get("a"); ok || nilDict.Len() != 0 || nilDict.Keys() != nil || nilDict.Values() != nil || nilDict.Pairs() != nil {
190+ t.Error("nil Dictionary is not empty")
191+ }
192+}
193+
194+func TestArray(t *testing.T) {
195+ a := NewArray(String("x"), nil)
196+ if a.Len() != 2 || a.At(0).(String) != "x" || a.At(1).Kind() != KindNull || a.At(2).Kind() != KindNull || a.At(-1).Kind() != KindNull {
197+ t.Errorf("At/Len wrong: %v", a)
198+ }
199+ a.Append(Number(3))
200+ if a.Len() != 3 || a.Filtered() {
201+ t.Error("Append/Filtered")
202+ }
203+ items := a.Items()
204+ items[0] = Null{}
205+ if a.At(0).Kind() != KindString {
206+ t.Error("Items does not copy")
207+ }
208+ var nilArr *Array
209+ if nilArr.Len() != 0 || nilArr.Items() != nil || nilArr.At(0).Kind() != KindNull || nilArr.Filtered() {
210+ t.Error("nil Array is not empty")
211+ }
212+ src := []Value{String("a")}
213+ b := NewArray(src...)
214+ src[0] = String("changed")
215+ if b.At(0).(String) != "a" {
216+ t.Error("NewArray does not copy")
217+ }
218+}
219+
220+func TestCoerceAndTruthy(t *testing.T) {
221+ for _, tc := range []struct {
222+ v Value
223+ s string
224+ truthy bool
225+ }{
226+ {nil, "", false},
227+ {Null{}, "", false},
228+ {Boolean(true), "true", true},
229+ {Boolean(false), "false", false},
230+ {Number(0), "0", false},
231+ {Number(math.Copysign(0, -1)), "0", false},
232+ {Number(math.NaN()), "NaN", false},
233+ {Number(2), "2", true},
234+ {Number(math.Inf(-1)), "-Infinity", true},
235+ {String(""), "", false},
236+ {String("0"), "0", true},
237+ {String("false"), "false", true},
238+ {NewArray(), "Array", true},
239+ {NewDictionary(), "Object", true},
240+ {(*Array)(nil), "", false},
241+ } {
242+ if got := CoerceString(tc.v); got != tc.s {
243+ t.Errorf("CoerceString(%#v) = %q, want %q", tc.v, got, tc.s)
244+ }
245+ if got := Truthy(tc.v); got != tc.truthy {
246+ t.Errorf("Truthy(%#v) = %v, want %v", tc.v, got, tc.truthy)
247+ }
248+ }
249+ if KindDictionary.String() != "Object" || Kind(99).String() != "unknown" {
250+ t.Error("Kind.String")
251+ }
252+}
253+
254+func TestToNumber(t *testing.T) {
255+ for _, tc := range []struct {
256+ v Value
257+ want float64
258+ }{
259+ {nil, 0}, {Null{}, 0}, {Boolean(true), 1}, {Boolean(false), 0}, {Number(2.5), 2.5}, {String(" 7 "), 7}, {String("x"), math.NaN()}, {NewArray(), math.NaN()}, {NewDictionary(), math.NaN()},
260+ } {
261+ got := toNumber(tc.v)
262+ if got != tc.want && !(math.IsNaN(got) && math.IsNaN(tc.want)) {
263+ t.Errorf("toNumber(%#v) = %v, want %v", tc.v, got, tc.want)
264+ }
265+ }
266+}
@@ -0,0 +1,266 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
6+ "math"
7+ "strings"
8+ "testing"
9+)
10+
11+func TestFromJSONValid(t *testing.T) {
12+ tests := []struct {
13+ in string
14+ want string // ToJSON of the result
15+ }{
16+ {"null", "null"},
17+ {" true ", "true"},
18+ {"\t\r\n false \n", "false"},
19+ {"0", "0"},
20+ {"-0", "0"},
21+ {"-12.5e1", "-125"},
22+ {"1E2", "100"},
23+ {"0.1e1", "1"},
24+ {`"a\u00e9\n"`, `"aé\n"`},
25+ {`"\ud83d\ude00"`, `"😀"`},
26+ {`"\ud800"`, `"\ud800"`},
27+ {`"\udc00x"`, `"\udc00x"`},
28+ {`"\ud800\u0041"`, `"\ud800A"`},
29+ {`"\/"`, `"/"`},
30+ {`[]`, `[]`},
31+ {`{}`, `{}`},
32+ {`[ 1 , [ ] , { } ]`, "[\n 1,\n [],\n {}\n]"},
33+ {`{"a":1,"A":2}`, "{\n \"a\": 2\n}"}, // see below: the last value of an exact duplicate wins, case variants collapse to the first
34+ {`{"a":1,"a":2}`, "{\n \"a\": 2\n}"},
35+ {`{"b":1,"1":2,"a":3,"0":4}`, "{\n \"0\": 4,\n \"1\": 2,\n \"b\": 1,\n \"a\": 3\n}"},
36+ }
37+ for _, tc := range tests {
38+ if tc.in == `{"a":1,"A":2}` {
39+ // "a" and "A" are distinct JSON keys but the same dictionary key:
40+ // the first one wins.
41+ tc.want = "{\n \"a\": 1\n}"
42+ }
43+ v, err := FromJSON([]byte(tc.in))
44+ if err != nil {
45+ t.Errorf("FromJSON(%q): %v", tc.in, err)
46+ continue
47+ }
48+ if got := ToJSON(v); got != tc.want {
49+ t.Errorf("FromJSON(%q) -> %q, want %q", tc.in, got, tc.want)
50+ }
51+ }
52+}
53+
54+func TestFromJSONInvalid(t *testing.T) {
55+ for _, in := range []string{
56+ "", " ", "{", "}", "[", "]", `{"a"`, `{"a":`, `{"a":1`, `{"a":1,}`, `{,}`, `{a:1}`, `{'a':1}`, `{"a" 1}`, `[1,]`, `[,1]`, `[1 2]`,
57+ "01", "1.", ".5", "+1", "-", "1e", "1e+", "--1", "0x10", "NaN", "Infinity", "-Infinity", "undefined", "nul", "truee", "tru", "True",
58+ `"abc`, `"a` + "\n" + `b"`, "\"\x01\"", `"\x"`, `"\u12"`, `"\u12g4"`, `"\`, "1 2", "[] []", "{} x", `"a""b"`, "\x00", "\u00a01", "1\u00a0", "\ufeff1",
59+ } {
60+ if v, err := FromJSON([]byte(in)); err == nil {
61+ t.Errorf("FromJSON(%q) = %v, want error", in, ToJSON(v))
62+ }
63+ }
64+}
65+
66+func TestFromJSONNumbers(t *testing.T) {
67+ for in, want := range map[string]float64{
68+ "1e999": math.Inf(1), "-1e999": math.Inf(-1), "1e-999": 0, "123456789012345678901234567890": 1.2345678901234568e29, "5e-324": 5e-324,
69+ } {
70+ v, err := FromJSON([]byte(in))
71+ if err != nil {
72+ t.Errorf("FromJSON(%q): %v", in, err)
73+ continue
74+ }
75+ if got := float64(v.(Number)); got != want {
76+ t.Errorf("FromJSON(%q) = %v, want %v", in, got, want)
77+ }
78+ }
79+ v, _ := FromJSON([]byte("-0"))
80+ if !math.Signbit(float64(v.(Number))) {
81+ t.Error("-0 lost its sign")
82+ }
83+}
84+
85+func TestFromJSONInvalidUTF8(t *testing.T) {
86+ v, err := FromJSON([]byte("\"a\xffb\""))
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ if s := string(v.(String)); s != "a\ufffdb" {
91+ t.Errorf("got %q", s)
92+ }
93+}
94+
95+func TestFromJSONDeepNesting(t *testing.T) {
96+ deep := strings.Repeat("[", 5000) + strings.Repeat("]", 5000)
97+ if _, err := FromJSON([]byte(deep)); err != nil {
98+ t.Errorf("depth 5000: %v", err)
99+ }
100+ tooDeep := strings.Repeat("[", maxJSONDepth+10) + strings.Repeat("]", maxJSONDepth+10)
101+ if _, err := FromJSON([]byte(tooDeep)); err == nil {
102+ t.Error("absurd depth accepted")
103+ }
104+}
105+
106+func TestToJSONFormatting(t *testing.T) {
107+ d := NewDictionary()
108+ d.Add("name", String("x"))
109+ d.Add("list", NewArray(Number(1), Null{}, Boolean(true), NewArray(), NewDictionary()))
110+ inner := NewDictionary()
111+ inner.Add("k", String("v"))
112+ d.Add("obj", inner)
113+ want := `{
114+ "name": "x",
115+ "list": [
116+ 1,
117+ null,
118+ true,
119+ [],
120+ {}
121+ ],
122+ "obj": {
123+ "k": "v"
124+ }
125+}`
126+ if got := ToJSON(d); got != want {
127+ t.Errorf("got\n%s\nwant\n%s", got, want)
128+ }
129+ tests := []struct {
130+ v Value
131+ want string
132+ }{
133+ {nil, "null"},
134+ {Null{}, "null"},
135+ {Number(math.NaN()), "null"},
136+ {Number(math.Inf(1)), "null"},
137+ {Number(math.Inf(-1)), "null"},
138+ {Number(math.Copysign(0, -1)), "0"},
139+ {Number(1e21), "1e+21"},
140+ {Number(1e-7), "1e-7"},
141+ {Number(123456789), "123456789"},
142+ {String("\u0000\u001f\u007f\u0080\u2028"), `"\u0000\u001f` + "\u007f\u0080\u2028" + `"`},
143+ {String("\"\\\b\f\n\r\t"), `"\"\\\b\f\n\r\t"`},
144+ {String("</script>&'"), `"</script>&'"`},
145+ {String("\xff"), "\"\ufffd\""},
146+ {(*Array)(nil), "null"},
147+ {(*Dictionary)(nil), "null"},
148+ {NewArray(nil), "[\n null\n]"},
149+ }
150+ for _, tc := range tests {
151+ if got := ToJSON(tc.v); got != tc.want {
152+ t.Errorf("ToJSON(%#v) = %q, want %q", tc.v, got, tc.want)
153+ }
154+ }
155+}
156+
157+func TestDictionary(t *testing.T) {
158+ d := NewDictionary()
159+ if !d.Add("Name", String("first")) {
160+ t.Fatal("Add failed")
161+ }
162+ if d.Add("NAME", String("second")) {
163+ t.Error("case variant was added")
164+ }
165+ d.Add("other", Number(1))
166+ if v, ok := d.Get("nAmE"); !ok || v.(String) != "first" {
167+ t.Errorf("Get = %v, %v", v, ok)
168+ }
169+ if _, ok := d.Get("missing"); ok {
170+ t.Error("Get of missing key succeeded")
171+ }
172+ if got := strings.Join(d.Keys(), ","); got != "Name,other" {
173+ t.Errorf("Keys = %s", got)
174+ }
175+ if d.Len() != 2 || len(d.Values()) != 2 || len(d.Pairs()) != 2 || d.Pairs()[0].Key != "Name" {
176+ t.Errorf("Len/Values/Pairs inconsistent: %d", d.Len())
177+ }
178+ // Non-ASCII keys fold like JavaScript's toLowerCase.
179+ d.Add("ΑΣ", String("sigma"))
180+ if _, ok := d.Get("ας"); !ok {
181+ t.Error("final sigma key not found")
182+ }
183+ var zero Dictionary
184+ zero.Add("a", Null{})
185+ if _, ok := zero.Get("A"); !ok {
186+ t.Error("zero Dictionary is not usable")
187+ }
188+ var nilDict *Dictionary
189+ if _, ok := nilDict.Get("a"); ok || nilDict.Len() != 0 || nilDict.Keys() != nil || nilDict.Values() != nil || nilDict.Pairs() != nil {
190+ t.Error("nil Dictionary is not empty")
191+ }
192+}
193+
194+func TestArray(t *testing.T) {
195+ a := NewArray(String("x"), nil)
196+ if a.Len() != 2 || a.At(0).(String) != "x" || a.At(1).Kind() != KindNull || a.At(2).Kind() != KindNull || a.At(-1).Kind() != KindNull {
197+ t.Errorf("At/Len wrong: %v", a)
198+ }
199+ a.Append(Number(3))
200+ if a.Len() != 3 || a.Filtered() {
201+ t.Error("Append/Filtered")
202+ }
203+ items := a.Items()
204+ items[0] = Null{}
205+ if a.At(0).Kind() != KindString {
206+ t.Error("Items does not copy")
207+ }
208+ var nilArr *Array
209+ if nilArr.Len() != 0 || nilArr.Items() != nil || nilArr.At(0).Kind() != KindNull || nilArr.Filtered() {
210+ t.Error("nil Array is not empty")
211+ }
212+ src := []Value{String("a")}
213+ b := NewArray(src...)
214+ src[0] = String("changed")
215+ if b.At(0).(String) != "a" {
216+ t.Error("NewArray does not copy")
217+ }
218+}
219+
220+func TestCoerceAndTruthy(t *testing.T) {
221+ for _, tc := range []struct {
222+ v Value
223+ s string
224+ truthy bool
225+ }{
226+ {nil, "", false},
227+ {Null{}, "", false},
228+ {Boolean(true), "true", true},
229+ {Boolean(false), "false", false},
230+ {Number(0), "0", false},
231+ {Number(math.Copysign(0, -1)), "0", false},
232+ {Number(math.NaN()), "NaN", false},
233+ {Number(2), "2", true},
234+ {Number(math.Inf(-1)), "-Infinity", true},
235+ {String(""), "", false},
236+ {String("0"), "0", true},
237+ {String("false"), "false", true},
238+ {NewArray(), "Array", true},
239+ {NewDictionary(), "Object", true},
240+ {(*Array)(nil), "", false},
241+ } {
242+ if got := CoerceString(tc.v); got != tc.s {
243+ t.Errorf("CoerceString(%#v) = %q, want %q", tc.v, got, tc.s)
244+ }
245+ if got := Truthy(tc.v); got != tc.truthy {
246+ t.Errorf("Truthy(%#v) = %v, want %v", tc.v, got, tc.truthy)
247+ }
248+ }
249+ if KindDictionary.String() != "Object" || Kind(99).String() != "unknown" {
250+ t.Error("Kind.String")
251+ }
252+}
253+
254+func TestToNumber(t *testing.T) {
255+ for _, tc := range []struct {
256+ v Value
257+ want float64
258+ }{
259+ {nil, 0}, {Null{}, 0}, {Boolean(true), 1}, {Boolean(false), 0}, {Number(2.5), 2.5}, {String(" 7 "), 7}, {String("x"), math.NaN()}, {NewArray(), math.NaN()}, {NewDictionary(), math.NaN()},
260+ } {
261+ got := toNumber(tc.v)
262+ if got != tc.want && !(math.IsNaN(got) && math.IsNaN(tc.want)) {
263+ t.Errorf("toNumber(%#v) = %v, want %v", tc.v, got, tc.want)
264+ }
265+ }
266+}
A expr/oracle_test.go +228 -0
@@ -0,0 +1,228 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
6+ "compress/gzip"
7+ "encoding/json"
8+ "fmt"
9+ "os"
10+ "testing"
11+)
12+
13+// The oracle file holds what the reference implementation (GitHub's
14+// @actions/expressions 0.3.61, run under Node) does on about 33 000
15+// expressions: hand-picked edge cases and a seeded random walk over the
16+// grammar. It is generated by gen/oracle.mjs. TestOracle replays it and
17+// demands identical tokens, error messages and positions, result kinds,
18+// string coercions and toJSON output.
19+
20+type oracleFile struct {
21+ Reference string `json:"reference"`
22+ Contexts map[string]json.RawMessage `json:"contexts"`
23+ Cases []oracleCase `json:"cases"`
24+}
25+
26+type oracleCase struct {
27+ E string `json:"e"`
28+ Tokens [][]any `json:"tokens"`
29+ LexErr *string `json:"lexErr"`
30+ ParseErr *string `json:"parseErr"`
31+ Pos []int `json:"pos"`
32+ Empty bool `json:"empty"`
33+ EvalErr *string `json:"evalErr"`
34+ R *oracleResult `json:"r"`
35+}
36+
37+type oracleResult struct {
38+ K string `json:"k"`
39+ S string `json:"s"`
40+ J string `json:"j"`
41+}
42+
43+var oracleFuncInfos = []FuncInfo{
44+ {Name: "success", MinArgs: 0, MaxArgs: 0},
45+ {Name: "always", MinArgs: 0, MaxArgs: 0},
46+ {Name: "hashFiles", MinArgs: 1, MaxArgs: 255},
47+}
48+
49+var oracleFuncs = map[string]Func{
50+ "success": func([]Value) (Value, error) { return Boolean(true), nil },
51+ "always": func([]Value) (Value, error) { return Boolean(true), nil },
52+ "hashFiles": func(args []Value) (Value, error) {
53+ s := "hash:"
54+ for i, a := range args {
55+ if i > 0 {
56+ s += "|"
57+ }
58+ s += CoerceString(a)
59+ }
60+ return String(s), nil
61+ },
62+}
63+
64+func kindName(v Value) string {
65+ switch v.Kind() {
66+ case KindNull:
67+ return "null"
68+ case KindBoolean:
69+ return "boolean"
70+ case KindNumber:
71+ return "number"
72+ case KindString:
73+ return "string"
74+ case KindArray:
75+ return "array"
76+ }
77+ return "object"
78+}
79+
80+func loadOracle(tb testing.TB) (*oracleFile, map[string]Value, []string) {
81+ tb.Helper()
82+ f, err := os.Open("testdata/oracle.json.gz")
83+ if err != nil {
84+ tb.Fatal(err)
85+ }
86+ defer func() { _ = f.Close() }()
87+ zr, err := gzip.NewReader(f)
88+ if err != nil {
89+ tb.Fatal(err)
90+ }
91+ var of oracleFile
92+ if err := json.NewDecoder(zr).Decode(&of); err != nil {
93+ tb.Fatal(err)
94+ }
95+ ctx := map[string]Value{}
96+ var names []string
97+ for name, raw := range of.Contexts {
98+ v, err := FromJSON(raw)
99+ if err != nil {
100+ tb.Fatalf("context %s: %v", name, err)
101+ }
102+ ctx[name] = v
103+ names = append(names, name)
104+ }
105+ return &of, ctx, names
106+}
107+
108+func TestOracle(t *testing.T) {
109+ of, ctxValues, ctxNames := loadOracle(t)
110+ if len(of.Cases) < 30000 {
111+ t.Fatalf("oracle has only %d cases", len(of.Cases))
112+ }
113+ ec := EvalContext{Contexts: ctxValues, Funcs: oracleFuncs}
114+ failures := 0
115+ fail := func(c oracleCase, format string, args ...any) {
116+ failures++
117+ if failures <= 40 {
118+ e := c.E
119+ if len(e) > 200 {
120+ e = e[:200] + "..."
121+ }
122+ t.Errorf("%q: %s", e, fmt.Sprintf(format, args...))
123+ }
124+ }
125+ var counts struct{ lexErr, parseErr, evalErr, ok, empty int }
126+ for _, c := range of.Cases {
127+ tokens, err := Lex(c.E)
128+ if c.LexErr != nil {
129+ counts.lexErr++
130+ if err == nil {
131+ fail(c, "lex: want error %q, got none", *c.LexErr)
132+ } else if err.Error() != *c.LexErr {
133+ fail(c, "lex error: want %q, got %q", *c.LexErr, err.Error())
134+ }
135+ continue
136+ }
137+ if err != nil {
138+ fail(c, "lex: unexpected error %v", err)
139+ continue
140+ }
141+ if len(tokens) != len(c.Tokens) {
142+ fail(c, "lex: %d tokens, want %d", len(tokens), len(c.Tokens))
143+ continue
144+ }
145+ for i, tk := range tokens {
146+ w := c.Tokens[i]
147+ got := []any{float64(tk.Type), tk.Lexeme, float64(tk.Range.Start.Line), float64(tk.Range.Start.Column), float64(tk.Range.End.Line), float64(tk.Range.End.Column)}
148+ for j := range got {
149+ if got[j] != w[j] {
150+ fail(c, "token %d field %d: got %v, want %v", i, j, got[j], w[j])
151+ }
152+ }
153+ var gv any
154+ switch tk.Type {
155+ case TokenNumber:
156+ f := float64(tk.Value.(Number))
157+ if f != f {
158+ gv = "NaN"
159+ } else {
160+ gv = jsNumberToString(f)
161+ }
162+ case TokenString:
163+ gv = string(tk.Value.(String))
164+ }
165+ if gv != w[6] {
166+ fail(c, "token %d value: got %v, want %v", i, gv, w[6])
167+ }
168+ }
169+
170+ node, err := Parse(c.E, ctxNames, oracleFuncInfos)
171+ if c.ParseErr != nil {
172+ counts.parseErr++
173+ var pe *Error
174+ switch {
175+ case err == nil:
176+ fail(c, "parse: want error %q, got none", *c.ParseErr)
177+ case err.Error() != *c.ParseErr:
178+ fail(c, "parse error: want %q, got %q", *c.ParseErr, err.Error())
179+ case func() bool { pe, _ = err.(*Error); return pe == nil }():
180+ fail(c, "parse error has type %T", err)
181+ case c.Pos != nil && (pe.Pos.Line != c.Pos[0] || pe.Pos.Column != c.Pos[1]):
182+ fail(c, "parse error position: got %v, want %v", pe.Pos, c.Pos)
183+ }
184+ continue
185+ }
186+ if err != nil {
187+ fail(c, "parse: unexpected error %v", err)
188+ continue
189+ }
190+ if c.Empty {
191+ counts.empty++
192+ if node != nil {
193+ fail(c, "parse: want empty")
194+ }
195+ continue
196+ }
197+
198+ v, err := Evaluate(node, ec)
199+ if c.EvalErr != nil {
200+ counts.evalErr++
201+ if err == nil {
202+ fail(c, "eval: want error %q, got %s", *c.EvalErr, CoerceString(v))
203+ } else if err.Error() != *c.EvalErr {
204+ fail(c, "eval error: want %q, got %q", *c.EvalErr, err.Error())
205+ }
206+ continue
207+ }
208+ if err != nil {
209+ fail(c, "eval: unexpected error %v", err)
210+ continue
211+ }
212+ counts.ok++
213+ if k := kindName(v); k != c.R.K {
214+ fail(c, "result kind: got %s, want %s", k, c.R.K)
215+ }
216+ if s := CoerceString(v); s != c.R.S {
217+ fail(c, "result string: got %q, want %q", s, c.R.S)
218+ }
219+ if j := ToJSON(v); j != c.R.J {
220+ fail(c, "result JSON: got %q, want %q", j, c.R.J)
221+ }
222+ }
223+ t.Logf("%d cases: %d lex errors, %d parse errors, %d empty, %d eval errors, %d values; %d failures",
224+ len(of.Cases), counts.lexErr, counts.parseErr, counts.empty, counts.evalErr, counts.ok, failures)
225+ if failures > 0 {
226+ t.Fatalf("%d oracle mismatches", failures)
227+ }
228+}
@@ -0,0 +1,228 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
6+ "compress/gzip"
7+ "encoding/json"
8+ "fmt"
9+ "os"
10+ "testing"
11+)
12+
13+// The oracle file holds what the reference implementation (GitHub's
14+// @actions/expressions 0.3.61, run under Node) does on about 33 000
15+// expressions: hand-picked edge cases and a seeded random walk over the
16+// grammar. It is generated by gen/oracle.mjs. TestOracle replays it and
17+// demands identical tokens, error messages and positions, result kinds,
18+// string coercions and toJSON output.
19+
20+type oracleFile struct {
21+ Reference string `json:"reference"`
22+ Contexts map[string]json.RawMessage `json:"contexts"`
23+ Cases []oracleCase `json:"cases"`
24+}
25+
26+type oracleCase struct {
27+ E string `json:"e"`
28+ Tokens [][]any `json:"tokens"`
29+ LexErr *string `json:"lexErr"`
30+ ParseErr *string `json:"parseErr"`
31+ Pos []int `json:"pos"`
32+ Empty bool `json:"empty"`
33+ EvalErr *string `json:"evalErr"`
34+ R *oracleResult `json:"r"`
35+}
36+
37+type oracleResult struct {
38+ K string `json:"k"`
39+ S string `json:"s"`
40+ J string `json:"j"`
41+}
42+
43+var oracleFuncInfos = []FuncInfo{
44+ {Name: "success", MinArgs: 0, MaxArgs: 0},
45+ {Name: "always", MinArgs: 0, MaxArgs: 0},
46+ {Name: "hashFiles", MinArgs: 1, MaxArgs: 255},
47+}
48+
49+var oracleFuncs = map[string]Func{
50+ "success": func([]Value) (Value, error) { return Boolean(true), nil },
51+ "always": func([]Value) (Value, error) { return Boolean(true), nil },
52+ "hashFiles": func(args []Value) (Value, error) {
53+ s := "hash:"
54+ for i, a := range args {
55+ if i > 0 {
56+ s += "|"
57+ }
58+ s += CoerceString(a)
59+ }
60+ return String(s), nil
61+ },
62+}
63+
64+func kindName(v Value) string {
65+ switch v.Kind() {
66+ case KindNull:
67+ return "null"
68+ case KindBoolean:
69+ return "boolean"
70+ case KindNumber:
71+ return "number"
72+ case KindString:
73+ return "string"
74+ case KindArray:
75+ return "array"
76+ }
77+ return "object"
78+}
79+
80+func loadOracle(tb testing.TB) (*oracleFile, map[string]Value, []string) {
81+ tb.Helper()
82+ f, err := os.Open("testdata/oracle.json.gz")
83+ if err != nil {
84+ tb.Fatal(err)
85+ }
86+ defer func() { _ = f.Close() }()
87+ zr, err := gzip.NewReader(f)
88+ if err != nil {
89+ tb.Fatal(err)
90+ }
91+ var of oracleFile
92+ if err := json.NewDecoder(zr).Decode(&of); err != nil {
93+ tb.Fatal(err)
94+ }
95+ ctx := map[string]Value{}
96+ var names []string
97+ for name, raw := range of.Contexts {
98+ v, err := FromJSON(raw)
99+ if err != nil {
100+ tb.Fatalf("context %s: %v", name, err)
101+ }
102+ ctx[name] = v
103+ names = append(names, name)
104+ }
105+ return &of, ctx, names
106+}
107+
108+func TestOracle(t *testing.T) {
109+ of, ctxValues, ctxNames := loadOracle(t)
110+ if len(of.Cases) < 30000 {
111+ t.Fatalf("oracle has only %d cases", len(of.Cases))
112+ }
113+ ec := EvalContext{Contexts: ctxValues, Funcs: oracleFuncs}
114+ failures := 0
115+ fail := func(c oracleCase, format string, args ...any) {
116+ failures++
117+ if failures <= 40 {
118+ e := c.E
119+ if len(e) > 200 {
120+ e = e[:200] + "..."
121+ }
122+ t.Errorf("%q: %s", e, fmt.Sprintf(format, args...))
123+ }
124+ }
125+ var counts struct{ lexErr, parseErr, evalErr, ok, empty int }
126+ for _, c := range of.Cases {
127+ tokens, err := Lex(c.E)
128+ if c.LexErr != nil {
129+ counts.lexErr++
130+ if err == nil {
131+ fail(c, "lex: want error %q, got none", *c.LexErr)
132+ } else if err.Error() != *c.LexErr {
133+ fail(c, "lex error: want %q, got %q", *c.LexErr, err.Error())
134+ }
135+ continue
136+ }
137+ if err != nil {
138+ fail(c, "lex: unexpected error %v", err)
139+ continue
140+ }
141+ if len(tokens) != len(c.Tokens) {
142+ fail(c, "lex: %d tokens, want %d", len(tokens), len(c.Tokens))
143+ continue
144+ }
145+ for i, tk := range tokens {
146+ w := c.Tokens[i]
147+ got := []any{float64(tk.Type), tk.Lexeme, float64(tk.Range.Start.Line), float64(tk.Range.Start.Column), float64(tk.Range.End.Line), float64(tk.Range.End.Column)}
148+ for j := range got {
149+ if got[j] != w[j] {
150+ fail(c, "token %d field %d: got %v, want %v", i, j, got[j], w[j])
151+ }
152+ }
153+ var gv any
154+ switch tk.Type {
155+ case TokenNumber:
156+ f := float64(tk.Value.(Number))
157+ if f != f {
158+ gv = "NaN"
159+ } else {
160+ gv = jsNumberToString(f)
161+ }
162+ case TokenString:
163+ gv = string(tk.Value.(String))
164+ }
165+ if gv != w[6] {
166+ fail(c, "token %d value: got %v, want %v", i, gv, w[6])
167+ }
168+ }
169+
170+ node, err := Parse(c.E, ctxNames, oracleFuncInfos)
171+ if c.ParseErr != nil {
172+ counts.parseErr++
173+ var pe *Error
174+ switch {
175+ case err == nil:
176+ fail(c, "parse: want error %q, got none", *c.ParseErr)
177+ case err.Error() != *c.ParseErr:
178+ fail(c, "parse error: want %q, got %q", *c.ParseErr, err.Error())
179+ case func() bool { pe, _ = err.(*Error); return pe == nil }():
180+ fail(c, "parse error has type %T", err)
181+ case c.Pos != nil && (pe.Pos.Line != c.Pos[0] || pe.Pos.Column != c.Pos[1]):
182+ fail(c, "parse error position: got %v, want %v", pe.Pos, c.Pos)
183+ }
184+ continue
185+ }
186+ if err != nil {
187+ fail(c, "parse: unexpected error %v", err)
188+ continue
189+ }
190+ if c.Empty {
191+ counts.empty++
192+ if node != nil {
193+ fail(c, "parse: want empty")
194+ }
195+ continue
196+ }
197+
198+ v, err := Evaluate(node, ec)
199+ if c.EvalErr != nil {
200+ counts.evalErr++
201+ if err == nil {
202+ fail(c, "eval: want error %q, got %s", *c.EvalErr, CoerceString(v))
203+ } else if err.Error() != *c.EvalErr {
204+ fail(c, "eval error: want %q, got %q", *c.EvalErr, err.Error())
205+ }
206+ continue
207+ }
208+ if err != nil {
209+ fail(c, "eval: unexpected error %v", err)
210+ continue
211+ }
212+ counts.ok++
213+ if k := kindName(v); k != c.R.K {
214+ fail(c, "result kind: got %s, want %s", k, c.R.K)
215+ }
216+ if s := CoerceString(v); s != c.R.S {
217+ fail(c, "result string: got %q, want %q", s, c.R.S)
218+ }
219+ if j := ToJSON(v); j != c.R.J {
220+ fail(c, "result JSON: got %q, want %q", j, c.R.J)
221+ }
222+ }
223+ t.Logf("%d cases: %d lex errors, %d parse errors, %d empty, %d eval errors, %d values; %d failures",
224+ len(of.Cases), counts.lexErr, counts.parseErr, counts.empty, counts.evalErr, counts.ok, failures)
225+ if failures > 0 {
226+ t.Fatalf("%d oracle mismatches", failures)
227+ }
228+}
A expr/testdata/SOURCE +4 -0
@@ -0,0 +1,4 @@
1+oracle.json.gz and tojson/*.golden are outputs of @actions/expressions 0.3.61
2+(https://github.com/actions/languageservices, MIT, Copyright GitHub, Inc.) run
3+on expressions and JSON inputs written for this repository. They are generated
4+by ../gen/oracle.mjs and ../gen/golden.mjs. See ../../THIRD_PARTY.md.
@@ -0,0 +1,4 @@
1+oracle.json.gz and tojson/*.golden are outputs of @actions/expressions 0.3.61
2+(https://github.com/actions/languageservices, MIT, Copyright GitHub, Inc.) run
3+on expressions and JSON inputs written for this repository. They are generated
4+by ../gen/oracle.mjs and ../gen/golden.mjs. See ../../THIRD_PARTY.md.
A expr/testdata/oracle.json.gz +0 -0 binary file: no text diff

binary file: no text diff

A expr/testdata/tojson/array_empty.golden +1 -0
@@ -0,0 +1,1 @@
1+[]⏎̸
@@ -0,0 +1,1 @@
1+[]⏎̸
A expr/testdata/tojson/array_empty.json +1 -0
@@ -0,0 +1,1 @@
1+[]
@@ -0,0 +1,1 @@
1+[]
A expr/testdata/tojson/array_flat.golden +5 -0
@@ -0,0 +1,5 @@
1+[
2+ 1,
3+ 2,
4+ 3
5+]⏎̸
@@ -0,0 +1,5 @@
1+[
2+ 1,
3+ 2,
4+ 3
5+]⏎̸
A expr/testdata/tojson/array_flat.json +1 -0
@@ -0,0 +1,1 @@
1+[1,2,3]
@@ -0,0 +1,1 @@
1+[1,2,3]
A expr/testdata/tojson/array_mixed.golden +8 -0
@@ -0,0 +1,8 @@
1+[
2+ null,
3+ true,
4+ "a",
5+ 1.5,
6+ [],
7+ {}
8+]⏎̸
@@ -0,0 +1,8 @@
1+[
2+ null,
3+ true,
4+ "a",
5+ 1.5,
6+ [],
7+ {}
8+]⏎̸
A expr/testdata/tojson/array_mixed.json +1 -0
@@ -0,0 +1,1 @@
1+[null,true,"a",1.5,[],{}]
@@ -0,0 +1,1 @@
1+[null,true,"a",1.5,[],{}]
A expr/testdata/tojson/array_nested.golden +13 -0
@@ -0,0 +1,13 @@
1+[
2+ [
3+ 1,
4+ [
5+ 2,
6+ [
7+ 3,
8+ []
9+ ]
10+ ]
11+ ],
12+ []
13+]⏎̸
@@ -0,0 +1,13 @@
1+[
2+ [
3+ 1,
4+ [
5+ 2,
6+ [
7+ 3,
8+ []
9+ ]
10+ ]
11+ ],
12+ []
13+]⏎̸
A expr/testdata/tojson/array_nested.json +1 -0
@@ -0,0 +1,1 @@
1+[[1,[2,[3,[]]]],[]]
@@ -0,0 +1,1 @@
1+[[1,[2,[3,[]]]],[]]
A expr/testdata/tojson/big_int.golden +1 -0
@@ -0,0 +1,1 @@
1+12345678901234567000⏎̸
@@ -0,0 +1,1 @@
1+12345678901234567000⏎̸
A expr/testdata/tojson/big_int.json +1 -0
@@ -0,0 +1,1 @@
1+12345678901234567890
@@ -0,0 +1,1 @@
1+12345678901234567890
A expr/testdata/tojson/exp_20.golden +1 -0
@@ -0,0 +1,1 @@
1+100000000000000000000⏎̸
@@ -0,0 +1,1 @@
1+100000000000000000000⏎̸
A expr/testdata/tojson/exp_20.json +1 -0
@@ -0,0 +1,1 @@
1+1e20
@@ -0,0 +1,1 @@
1+1e20
A expr/testdata/tojson/exp_large.golden +1 -0
@@ -0,0 +1,1 @@
1+1e+21⏎̸
@@ -0,0 +1,1 @@
1+1e+21⏎̸
A expr/testdata/tojson/exp_large.json +1 -0
@@ -0,0 +1,1 @@
1+1e21
@@ -0,0 +1,1 @@
1+1e21
A expr/testdata/tojson/exp_pos.golden +1 -0
@@ -0,0 +1,1 @@
1+100⏎̸
@@ -0,0 +1,1 @@
1+100⏎̸
A expr/testdata/tojson/exp_pos.json +1 -0
@@ -0,0 +1,1 @@
1+1E2
@@ -0,0 +1,1 @@
1+1E2
A expr/testdata/tojson/exp_small.golden +1 -0
@@ -0,0 +1,1 @@
1+1e-7⏎̸
@@ -0,0 +1,1 @@
1+1e-7⏎̸
A expr/testdata/tojson/exp_small.json +1 -0
@@ -0,0 +1,1 @@
1+1e-7
@@ -0,0 +1,1 @@
1+1e-7
A expr/testdata/tojson/false.golden +1 -0
@@ -0,0 +1,1 @@
1+false⏎̸
@@ -0,0 +1,1 @@
1+false⏎̸
A expr/testdata/tojson/false.json +1 -0
@@ -0,0 +1,1 @@
1+false
@@ -0,0 +1,1 @@
1+false
A expr/testdata/tojson/float.golden +1 -0
@@ -0,0 +1,1 @@
1+1.5⏎̸
@@ -0,0 +1,1 @@
1+1.5⏎̸
A expr/testdata/tojson/float.json +1 -0
@@ -0,0 +1,1 @@
1+1.5
@@ -0,0 +1,1 @@
1+1.5
A expr/testdata/tojson/github_event.golden +27 -0
@@ -0,0 +1,27 @@
1+{
2+ "action": "opened",
3+ "number": 1,
4+ "pull_request": {
5+ "id": 1,
6+ "draft": false,
7+ "labels": [
8+ {
9+ "name": "bug",
10+ "color": "d73a4a"
11+ },
12+ {
13+ "name": "docs",
14+ "color": null
15+ }
16+ ],
17+ "head": {
18+ "ref": "feat/x",
19+ "sha": "abc123"
20+ }
21+ },
22+ "repository": {
23+ "full_name": "octo/repo",
24+ "private": true,
25+ "topics": []
26+ }
27+}⏎̸
@@ -0,0 +1,27 @@
1+{
2+ "action": "opened",
3+ "number": 1,
4+ "pull_request": {
5+ "id": 1,
6+ "draft": false,
7+ "labels": [
8+ {
9+ "name": "bug",
10+ "color": "d73a4a"
11+ },
12+ {
13+ "name": "docs",
14+ "color": null
15+ }
16+ ],
17+ "head": {
18+ "ref": "feat/x",
19+ "sha": "abc123"
20+ }
21+ },
22+ "repository": {
23+ "full_name": "octo/repo",
24+ "private": true,
25+ "topics": []
26+ }
27+}⏎̸
A expr/testdata/tojson/github_event.json +1 -0
@@ -0,0 +1,1 @@
1+{"action":"opened","number":1,"pull_request":{"id":1,"draft":false,"labels":[{"name":"bug","color":"d73a4a"},{"name":"docs","color":null}],"head":{"ref":"feat/x","sha":"abc123"}},"repository":{"full_name":"octo/repo","private":true,"topics":[]}}
@@ -0,0 +1,1 @@
1+{"action":"opened","number":1,"pull_request":{"id":1,"draft":false,"labels":[{"name":"bug","color":"d73a4a"},{"name":"docs","color":null}],"head":{"ref":"feat/x","sha":"abc123"}},"repository":{"full_name":"octo/repo","private":true,"topics":[]}}
A expr/testdata/tojson/int.golden +1 -0
@@ -0,0 +1,1 @@
1+42⏎̸
@@ -0,0 +1,1 @@
1+42⏎̸
A expr/testdata/tojson/int.json +1 -0
@@ -0,0 +1,1 @@
1+42
@@ -0,0 +1,1 @@
1+42
A expr/testdata/tojson/max_double.golden +1 -0
@@ -0,0 +1,1 @@
1+1.7976931348623157e+308⏎̸
@@ -0,0 +1,1 @@
1+1.7976931348623157e+308⏎̸
A expr/testdata/tojson/max_double.json +1 -0
@@ -0,0 +1,1 @@
1+1.7976931348623157e308
@@ -0,0 +1,1 @@
1+1.7976931348623157e308
A expr/testdata/tojson/min_double.golden +1 -0
@@ -0,0 +1,1 @@
1+5e-324⏎̸
@@ -0,0 +1,1 @@
1+5e-324⏎̸
A expr/testdata/tojson/min_double.json +1 -0
@@ -0,0 +1,1 @@
1+5e-324
@@ -0,0 +1,1 @@
1+5e-324
A expr/testdata/tojson/negative.golden +1 -0
@@ -0,0 +1,1 @@
1+-17⏎̸
@@ -0,0 +1,1 @@
1+-17⏎̸
A expr/testdata/tojson/negative.json +1 -0
@@ -0,0 +1,1 @@
1+-17
@@ -0,0 +1,1 @@
1+-17
A expr/testdata/tojson/negzero.golden +1 -0
@@ -0,0 +1,1 @@
1+0⏎̸
@@ -0,0 +1,1 @@
1+0⏎̸
A expr/testdata/tojson/negzero.json +1 -0
@@ -0,0 +1,1 @@
1+-0
@@ -0,0 +1,1 @@
1+-0
A expr/testdata/tojson/null.golden +1 -0
@@ -0,0 +1,1 @@
1+null⏎̸
@@ -0,0 +1,1 @@
1+null⏎̸
A expr/testdata/tojson/null.json +1 -0
@@ -0,0 +1,1 @@
1+null
@@ -0,0 +1,1 @@
1+null
A expr/testdata/tojson/object_case_dup.golden +4 -0
@@ -0,0 +1,4 @@
1+{
2+ "a": 1,
3+ "b": 3
4+}⏎̸
@@ -0,0 +1,4 @@
1+{
2+ "a": 1,
3+ "b": 3
4+}⏎̸
A expr/testdata/tojson/object_case_dup.json +1 -0
@@ -0,0 +1,1 @@
1+{"a":1,"A":2,"b":3,"B":4}
@@ -0,0 +1,1 @@
1+{"a":1,"A":2,"b":3,"B":4}
A expr/testdata/tojson/object_dup_key.golden +4 -0
@@ -0,0 +1,4 @@
1+{
2+ "a": 3,
3+ "b": 2
4+}⏎̸
@@ -0,0 +1,4 @@
1+{
2+ "a": 3,
3+ "b": 2
4+}⏎̸
A expr/testdata/tojson/object_dup_key.json +1 -0
@@ -0,0 +1,1 @@
1+{"a":1,"b":2,"a":3}
@@ -0,0 +1,1 @@
1+{"a":1,"b":2,"a":3}
A expr/testdata/tojson/object_empty.golden +1 -0
@@ -0,0 +1,1 @@
1+{}⏎̸
@@ -0,0 +1,1 @@
1+{}⏎̸
A expr/testdata/tojson/object_empty.json +1 -0
@@ -0,0 +1,1 @@
1+{}
@@ -0,0 +1,1 @@
1+{}
A expr/testdata/tojson/object_empty_key.golden +4 -0
@@ -0,0 +1,4 @@
1+{
2+ "": 1,
3+ "a": 2
4+}⏎̸
@@ -0,0 +1,4 @@
1+{
2+ "": 1,
3+ "a": 2
4+}⏎̸
A expr/testdata/tojson/object_empty_key.json +1 -0
@@ -0,0 +1,1 @@
1+{"":1,"a":2}
@@ -0,0 +1,1 @@
1+{"":1,"a":2}
A expr/testdata/tojson/object_flat.golden +5 -0
@@ -0,0 +1,5 @@
1+{
2+ "a": 1,
3+ "b": "two",
4+ "c": null
5+}⏎̸
@@ -0,0 +1,5 @@
1+{
2+ "a": 1,
3+ "b": "two",
4+ "c": null
5+}⏎̸
A expr/testdata/tojson/object_flat.json +1 -0
@@ -0,0 +1,1 @@
1+{"a":1,"b":"two","c":null}
@@ -0,0 +1,1 @@
1+{"a":1,"b":"two","c":null}
A expr/testdata/tojson/object_key_escapes.golden +5 -0
@@ -0,0 +1,5 @@
1+{
2+ "quote\"key": 1,
3+ "new\nline": 2,
4+ "é": 3
5+}⏎̸
@@ -0,0 +1,5 @@
1+{
2+ "quote\"key": 1,
3+ "new\nline": 2,
4+ "é": 3
5+}⏎̸
A expr/testdata/tojson/object_key_escapes.json +1 -0
@@ -0,0 +1,1 @@
1+{"quote\"key":1,"new\nline":2,"\u00e9":3}
@@ -0,0 +1,1 @@
1+{"quote\"key":1,"new\nline":2,"\u00e9":3}
A expr/testdata/tojson/object_nested.golden +15 -0
@@ -0,0 +1,15 @@
1+{
2+ "a": {
3+ "b": {
4+ "c": {
5+ "d": [
6+ 1,
7+ {
8+ "e": 2
9+ }
10+ ]
11+ }
12+ }
13+ },
14+ "f": []
15+}⏎̸
@@ -0,0 +1,15 @@
1+{
2+ "a": {
3+ "b": {
4+ "c": {
5+ "d": [
6+ 1,
7+ {
8+ "e": 2
9+ }
10+ ]
11+ }
12+ }
13+ },
14+ "f": []
15+}⏎̸
A expr/testdata/tojson/object_nested.json +1 -0
@@ -0,0 +1,1 @@
1+{"a":{"b":{"c":{"d":[1,{"e":2}]}}},"f":[]}
@@ -0,0 +1,1 @@
1+{"a":{"b":{"c":{"d":[1,{"e":2}]}}},"f":[]}
A expr/testdata/tojson/object_numeric_keys.golden +11 -0
@@ -0,0 +1,11 @@
1+{
2+ "1": 6,
3+ "2": 3,
4+ "10": 2,
5+ "4294967294": 7,
6+ "b": 1,
7+ "a": 4,
8+ "01": 5,
9+ "4294967295": 8,
10+ "-1": 9
11+}⏎̸
@@ -0,0 +1,11 @@
1+{
2+ "1": 6,
3+ "2": 3,
4+ "10": 2,
5+ "4294967294": 7,
6+ "b": 1,
7+ "a": 4,
8+ "01": 5,
9+ "4294967295": 8,
10+ "-1": 9
11+}⏎̸
A expr/testdata/tojson/object_numeric_keys.json +1 -0
@@ -0,0 +1,1 @@
1+{"b":1,"10":2,"2":3,"a":4,"01":5,"1":6,"4294967294":7,"4294967295":8,"-1":9}
@@ -0,0 +1,1 @@
1+{"b":1,"10":2,"2":3,"a":4,"01":5,"1":6,"4294967294":7,"4294967295":8,"-1":9}
A expr/testdata/tojson/object_order.golden +5 -0
@@ -0,0 +1,5 @@
1+{
2+ "b": 1,
3+ "a": 2,
4+ "c": 3
5+}⏎̸
@@ -0,0 +1,5 @@
1+{
2+ "b": 1,
3+ "a": 2,
4+ "c": 3
5+}⏎̸
A expr/testdata/tojson/object_order.json +1 -0
@@ -0,0 +1,1 @@
1+{"b":1,"a":2,"c":3}
@@ -0,0 +1,1 @@
1+{"b":1,"a":2,"c":3}
A expr/testdata/tojson/overflow.golden +1 -0
@@ -0,0 +1,1 @@
1+null⏎̸
@@ -0,0 +1,1 @@
1+null⏎̸
A expr/testdata/tojson/overflow.json +1 -0
@@ -0,0 +1,1 @@
1+1e999
@@ -0,0 +1,1 @@
1+1e999
A expr/testdata/tojson/precision.golden +1 -0
@@ -0,0 +1,1 @@
1+0.30000000000000004⏎̸
@@ -0,0 +1,1 @@
1+0.30000000000000004⏎̸
A expr/testdata/tojson/precision.json +1 -0
@@ -0,0 +1,1 @@
1+0.30000000000000004
@@ -0,0 +1,1 @@
1+0.30000000000000004
A expr/testdata/tojson/string_control.golden +1 -0
@@ -0,0 +1,1 @@
1+"\u0000\u0001\u001f<U+007F><U+0080>"⏎̸
@@ -0,0 +1,1 @@
1+"\u0000\u0001\u001f<U+007F><U+0080>"⏎̸
A expr/testdata/tojson/string_control.json +1 -0
@@ -0,0 +1,1 @@
1+"\u0000\u0001\u001f\u007f\u0080"
@@ -0,0 +1,1 @@
1+"\u0000\u0001\u001f\u007f\u0080"
A expr/testdata/tojson/string_empty.golden +1 -0
@@ -0,0 +1,1 @@
1+""⏎̸
@@ -0,0 +1,1 @@
1+""⏎̸
A expr/testdata/tojson/string_empty.json +1 -0
@@ -0,0 +1,1 @@
1+""
@@ -0,0 +1,1 @@
1+""
A expr/testdata/tojson/string_escapes.golden +1 -0
@@ -0,0 +1,1 @@
1+"quote \" backslash \\ slash / nl \n cr \r tab \t bs \b ff \f"⏎̸
@@ -0,0 +1,1 @@
1+"quote \" backslash \\ slash / nl \n cr \r tab \t bs \b ff \f"⏎̸
A expr/testdata/tojson/string_escapes.json +1 -0
@@ -0,0 +1,1 @@
1+"quote \" backslash \\ slash \/ nl \n cr \r tab \t bs \b ff \f"
@@ -0,0 +1,1 @@
1+"quote \" backslash \\ slash \/ nl \n cr \r tab \t bs \b ff \f"
A expr/testdata/tojson/string_lone_surrogate.golden +1 -0
@@ -0,0 +1,1 @@
1+"\ud800"⏎̸
@@ -0,0 +1,1 @@
1+"\ud800"⏎̸
A expr/testdata/tojson/string_lone_surrogate.json +1 -0
@@ -0,0 +1,1 @@
1+"\ud800"
@@ -0,0 +1,1 @@
1+"\ud800"
A expr/testdata/tojson/string_plain.golden +1 -0
@@ -0,0 +1,1 @@
1+"hello"⏎̸
@@ -0,0 +1,1 @@
1+"hello"⏎̸
A expr/testdata/tojson/string_plain.json +1 -0
@@ -0,0 +1,1 @@
1+"hello"
@@ -0,0 +1,1 @@
1+"hello"
A expr/testdata/tojson/string_unicode.golden +1 -0
@@ -0,0 +1,1 @@
1+"héllo 世界 😀 <U+2028><U+2029>"⏎̸
@@ -0,0 +1,1 @@
1+"héllo 世界 😀 <U+2028><U+2029>"⏎̸
A expr/testdata/tojson/string_unicode.json +1 -0
@@ -0,0 +1,1 @@
1+"h\u00e9llo \u4e16\u754c \ud83d\ude00 \u2028\u2029"
@@ -0,0 +1,1 @@
1+"h\u00e9llo \u4e16\u754c \ud83d\ude00 \u2028\u2029"
A expr/testdata/tojson/true.golden +1 -0
@@ -0,0 +1,1 @@
1+true⏎̸
@@ -0,0 +1,1 @@
1+true⏎̸
A expr/testdata/tojson/true.json +1 -0
@@ -0,0 +1,1 @@
1+true
@@ -0,0 +1,1 @@
1+true
A expr/testdata/tojson/zero.golden +1 -0
@@ -0,0 +1,1 @@
1+0⏎̸
@@ -0,0 +1,1 @@
1+0⏎̸
A expr/testdata/tojson/zero.json +1 -0
@@ -0,0 +1,1 @@
1+0
@@ -0,0 +1,1 @@
1+0
A expr/testutil_test.go +59 -0
@@ -0,0 +1,59 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import "testing"
6+
7+// mustJSON parses JSON text into a Value or fails the test.
8+func mustJSON(tb testing.TB, s string) Value {
9+ tb.Helper()
10+ v, err := FromJSON([]byte(s))
11+ if err != nil {
12+ tb.Fatalf("FromJSON(%q): %v", s, err)
13+ }
14+ return v
15+}
16+
17+// testContexts is the environment of the table tests.
18+func testContexts(tb testing.TB) map[string]Value {
19+ tb.Helper()
20+ return map[string]Value{
21+ "github": mustJSON(tb, `{
22+ "event_name": "push",
23+ "ref": "refs/heads/main",
24+ "actor": "Octocat",
25+ "run_number": 7,
26+ "event": {
27+ "number": 12,
28+ "commits": [
29+ {"id": "1", "message": "fix: bug", "author": {"name": "Ann"}, "added": ["a.txt"]},
30+ {"id": "2", "message": "feat: thing", "author": {"name": "Bob"}, "added": []},
31+ {"id": "3", "message": "Chore", "added": ["b", "c"]}
32+ ],
33+ "issue": {"labels": [{"name": "bug"}, {"name": "Docs"}]},
34+ "nested": [[1, 2], [3], [], [4, 5, 6]]
35+ }
36+ }`),
37+ "env": mustJSON(tb, `{"FOO": "bar", "EMPTY": "", "NUM": "42", "Mixed": "MiXeD"}`),
38+ "matrix": mustJSON(tb, `{"os": "ubuntu", "node": [14, 16, 18], "nothing": null, "ver": "3.10", "obj": {"k": "v"}}`),
39+ "inputs": mustJSON(tb, `{"name": "World", "count": 3, "deploy": false, "empty": {}}`),
40+ "steps": mustJSON(tb, `{"10": {"outputs": {"r": "ten"}}, "2": {"outputs": {"r": "two"}}}`),
41+ }
42+}
43+
44+var testContextNames = []string{"github", "env", "matrix", "inputs", "steps"}
45+
46+// evalString parses and evaluates src against the test contexts and returns
47+// the result as "kind:string" or the error text prefixed by "ERR ".
48+func evalString(tb testing.TB, src string) string {
49+ tb.Helper()
50+ node, err := Parse(src, testContextNames, nil)
51+ if err != nil {
52+ return "PARSE " + err.Error()
53+ }
54+ v, err := Evaluate(node, EvalContext{Contexts: testContexts(tb)})
55+ if err != nil {
56+ return "ERR " + err.Error()
57+ }
58+ return v.Kind().String() + ":" + CoerceString(v)
59+}
@@ -0,0 +1,59 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import "testing"
6+
7+// mustJSON parses JSON text into a Value or fails the test.
8+func mustJSON(tb testing.TB, s string) Value {
9+ tb.Helper()
10+ v, err := FromJSON([]byte(s))
11+ if err != nil {
12+ tb.Fatalf("FromJSON(%q): %v", s, err)
13+ }
14+ return v
15+}
16+
17+// testContexts is the environment of the table tests.
18+func testContexts(tb testing.TB) map[string]Value {
19+ tb.Helper()
20+ return map[string]Value{
21+ "github": mustJSON(tb, `{
22+ "event_name": "push",
23+ "ref": "refs/heads/main",
24+ "actor": "Octocat",
25+ "run_number": 7,
26+ "event": {
27+ "number": 12,
28+ "commits": [
29+ {"id": "1", "message": "fix: bug", "author": {"name": "Ann"}, "added": ["a.txt"]},
30+ {"id": "2", "message": "feat: thing", "author": {"name": "Bob"}, "added": []},
31+ {"id": "3", "message": "Chore", "added": ["b", "c"]}
32+ ],
33+ "issue": {"labels": [{"name": "bug"}, {"name": "Docs"}]},
34+ "nested": [[1, 2], [3], [], [4, 5, 6]]
35+ }
36+ }`),
37+ "env": mustJSON(tb, `{"FOO": "bar", "EMPTY": "", "NUM": "42", "Mixed": "MiXeD"}`),
38+ "matrix": mustJSON(tb, `{"os": "ubuntu", "node": [14, 16, 18], "nothing": null, "ver": "3.10", "obj": {"k": "v"}}`),
39+ "inputs": mustJSON(tb, `{"name": "World", "count": 3, "deploy": false, "empty": {}}`),
40+ "steps": mustJSON(tb, `{"10": {"outputs": {"r": "ten"}}, "2": {"outputs": {"r": "two"}}}`),
41+ }
42+}
43+
44+var testContextNames = []string{"github", "env", "matrix", "inputs", "steps"}
45+
46+// evalString parses and evaluates src against the test contexts and returns
47+// the result as "kind:string" or the error text prefixed by "ERR ".
48+func evalString(tb testing.TB, src string) string {
49+ tb.Helper()
50+ node, err := Parse(src, testContextNames, nil)
51+ if err != nil {
52+ return "PARSE " + err.Error()
53+ }
54+ v, err := Evaluate(node, EvalContext{Contexts: testContexts(tb)})
55+ if err != nil {
56+ return "ERR " + err.Error()
57+ }
58+ return v.Kind().String() + ":" + CoerceString(v)
59+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close