test(expr): add oracle, golden, table and fuzz tests
- commit
- c82ace704b75b0631b751618454200640c1d80e8
- author
- brice · · 2026-10-12 14:46 UTC
- parent
- 7f4d0a4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||