detached 3a9395e

test(expr): port the runner's ExpressionParserL0 and ConditionFunctionsL0

commit
3a9395e2da373df6400dcde6c550cce647ffe77e
author
brice · · 2026-10-12 17:11 UTC
parent
9588af7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +159 -0
A expr/runner_l0_test.go +159 -0
@@ -0,0 +1,159 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
6+ "context"
7+ "strings"
8+ "testing"
9+)
10+
11+// Ports of the expression-semantic L0 tests of actions/runner
12+// (commit 5f0b3ddcc6ee9fe9441e00d55e85b94ea4aad798, MIT, GitHub, Inc.):
13+//
14+// src/Test/L0/Sdk/ExpressionParserL0.cs
15+// src/Test/L0/Worker/Expressions/ConditionFunctionsL0.cs
16+
17+// TestRunnerExpressionParserL0 ports ExpressionParserL0: the parser must
18+// reject names that were not declared (the regression was a positional
19+// argument that turned on "allow unknown keywords" for every parse).
20+func TestRunnerExpressionParserL0(t *testing.T) {
21+ tests := []struct {
22+ name string // the C# test method
23+ src string
24+ contexts []string
25+ wantErr string // substring of the error; empty means success
26+ }{
27+ {"CreateTree_RejectsUnrecognizedNamedValue", "github.event.repository.private", []string{"inputs"}, "Unrecognized named-value"},
28+ {"CreateTree_AcceptsRecognizedNamedValue", "inputs.foo", []string{"inputs"}, ""},
29+ {"CreateTree_CaseFunctionWorks", "case(github.event_name, 'push', 'Push Event')", []string{"github"}, ""},
30+ {"CreateTree_CaseFunctionDoesNotAffectUnknownKeywords", "github.ref", []string{"inputs"}, "Unrecognized named-value"},
31+ }
32+ for _, tc := range tests {
33+ t.Run(tc.name, func(t *testing.T) {
34+ node, err := Parse(tc.src, tc.contexts, nil)
35+ if tc.wantErr != "" {
36+ if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
37+ t.Fatalf("Parse(%q) error = %v, want one containing %q", tc.src, err, tc.wantErr)
38+ }
39+ return
40+ }
41+ if err != nil || node == nil {
42+ t.Fatalf("Parse(%q) = %v, %v; want a tree", tc.src, node, err)
43+ }
44+ })
45+ }
46+}
47+
48+// Job and action results, as the runner's ActionResult enum names them.
49+const (
50+ resNone = ""
51+ resSuccess = "Success"
52+ resFailure = "Failure"
53+ resCancelled = "Cancelled"
54+)
55+
56+// statusFuncs mirrors src/Runner.Worker/Expressions/{Always,Cancelled,Failure,
57+// Success}Function.cs. A missing job status counts as Success. In a composite
58+// action's main step (isEmbedded and main stage) failure() and success() read
59+// the action status instead of the job status; cancelled() and always() never do.
60+func statusFuncs(jobStatus, actionStatus string, compositeMain bool) (map[string]Func, []FuncInfo) {
61+ orSuccess := func(s string) string {
62+ if s == resNone {
63+ return resSuccess
64+ }
65+ return s
66+ }
67+ status := func() string {
68+ if compositeMain {
69+ return orSuccess(actionStatus)
70+ }
71+ return orSuccess(jobStatus)
72+ }
73+ fn := func(f func() bool) Func {
74+ return func(context.Context, []Value) (Value, error) { return Boolean(f()), nil }
75+ }
76+ funcs := map[string]Func{
77+ "always": fn(func() bool { return true }),
78+ "cancelled": fn(func() bool { return orSuccess(jobStatus) == resCancelled }),
79+ "failure": fn(func() bool { return status() == resFailure }),
80+ "success": fn(func() bool { return status() == resSuccess }),
81+ }
82+ var infos []FuncInfo
83+ for _, n := range []string{"always", "cancelled", "failure", "success"} {
84+ infos = append(infos, FuncInfo{Name: n, MinArgs: 0, MaxArgs: 0})
85+ }
86+ return funcs, infos
87+}
88+
89+func evalCondition(t *testing.T, src, jobStatus, actionStatus string, compositeMain bool) bool {
90+ t.Helper()
91+ funcs, infos := statusFuncs(jobStatus, actionStatus, compositeMain)
92+ node, err := Parse(src, nil, infos)
93+ if err != nil {
94+ t.Fatalf("Parse(%q): %v", src, err)
95+ }
96+ v, err := Evaluate(context.Background(), node, EvalContext{Funcs: funcs})
97+ if err != nil {
98+ t.Fatalf("Evaluate(%q): %v", src, err)
99+ }
100+ return Truthy(v)
101+}
102+
103+// TestRunnerConditionFunctionsL0 ports AlwaysFunction, CancelledFunction,
104+// FailureFunction and SuccessFunction of ConditionFunctionsL0, and the two
105+// composite-action theories. The functions themselves live in the runner; what
106+// is checked here is that package expr carries zero-argument extension
107+// functions the way the runner declares them (FunctionInfo(name, 0, 0)).
108+func TestRunnerConditionFunctionsL0(t *testing.T) {
109+ jobStatuses := []string{resNone, resCancelled, resFailure, resSuccess}
110+ want := map[string]map[string]bool{
111+ "always": {resNone: true, resCancelled: true, resFailure: true, resSuccess: true},
112+ "cancelled": {resNone: false, resCancelled: true, resFailure: false, resSuccess: false},
113+ "failure": {resNone: false, resCancelled: false, resFailure: true, resSuccess: false},
114+ "success": {resNone: true, resCancelled: false, resFailure: false, resSuccess: true},
115+ }
116+ for name, byStatus := range want {
117+ for _, js := range jobStatuses {
118+ if got := evalCondition(t, name+"()", js, "", false); got != byStatus[js] {
119+ t.Errorf("%s() with job status %q = %v, want %v", name, js, got, byStatus[js])
120+ }
121+ // Names are case-insensitive, as in the runner's dictionaries.
122+ if got := evalCondition(t, strings.ToUpper(name)+"()", js, "", false); got != byStatus[js] {
123+ t.Errorf("%s() with job status %q = %v, want %v", strings.ToUpper(name), js, got, byStatus[js])
124+ }
125+ }
126+ }
127+
128+ composite := []struct {
129+ fn string
130+ job, action string
131+ want bool
132+ }{
133+ {"failure", resFailure, resFailure, true},
134+ {"failure", resFailure, resSuccess, false},
135+ {"failure", resSuccess, resFailure, true},
136+ {"failure", resSuccess, resSuccess, false},
137+ {"failure", resSuccess, resNone, false},
138+ {"success", resFailure, resFailure, false},
139+ {"success", resFailure, resSuccess, true},
140+ {"success", resSuccess, resFailure, false},
141+ {"success", resSuccess, resSuccess, true},
142+ {"success", resSuccess, resNone, true},
143+ }
144+ for _, tc := range composite {
145+ if got := evalCondition(t, tc.fn+"()", tc.job, tc.action, true); got != tc.want {
146+ t.Errorf("%s() in a composite main step, job %q action %q = %v, want %v", tc.fn, tc.job, tc.action, got, tc.want)
147+ }
148+ }
149+
150+ // Arity is declared 0..0: the parser rejects arguments.
151+ _, infos := statusFuncs("", "", false)
152+ if _, err := Parse("success(1)", nil, infos); err == nil || !strings.Contains(err.Error(), "Too many parameters supplied") {
153+ t.Errorf("success(1) = %v, want Too many parameters supplied", err)
154+ }
155+ // A condition combining them: runner conditions are ordinary expressions.
156+ if !evalCondition(t, "failure() || cancelled()", resCancelled, "", false) || evalCondition(t, "!always()", resFailure, "", false) {
157+ t.Error("combined status conditions")
158+ }
159+}
@@ -0,0 +1,159 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
6+ "context"
7+ "strings"
8+ "testing"
9+)
10+
11+// Ports of the expression-semantic L0 tests of actions/runner
12+// (commit 5f0b3ddcc6ee9fe9441e00d55e85b94ea4aad798, MIT, GitHub, Inc.):
13+//
14+// src/Test/L0/Sdk/ExpressionParserL0.cs
15+// src/Test/L0/Worker/Expressions/ConditionFunctionsL0.cs
16+
17+// TestRunnerExpressionParserL0 ports ExpressionParserL0: the parser must
18+// reject names that were not declared (the regression was a positional
19+// argument that turned on "allow unknown keywords" for every parse).
20+func TestRunnerExpressionParserL0(t *testing.T) {
21+ tests := []struct {
22+ name string // the C# test method
23+ src string
24+ contexts []string
25+ wantErr string // substring of the error; empty means success
26+ }{
27+ {"CreateTree_RejectsUnrecognizedNamedValue", "github.event.repository.private", []string{"inputs"}, "Unrecognized named-value"},
28+ {"CreateTree_AcceptsRecognizedNamedValue", "inputs.foo", []string{"inputs"}, ""},
29+ {"CreateTree_CaseFunctionWorks", "case(github.event_name, 'push', 'Push Event')", []string{"github"}, ""},
30+ {"CreateTree_CaseFunctionDoesNotAffectUnknownKeywords", "github.ref", []string{"inputs"}, "Unrecognized named-value"},
31+ }
32+ for _, tc := range tests {
33+ t.Run(tc.name, func(t *testing.T) {
34+ node, err := Parse(tc.src, tc.contexts, nil)
35+ if tc.wantErr != "" {
36+ if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
37+ t.Fatalf("Parse(%q) error = %v, want one containing %q", tc.src, err, tc.wantErr)
38+ }
39+ return
40+ }
41+ if err != nil || node == nil {
42+ t.Fatalf("Parse(%q) = %v, %v; want a tree", tc.src, node, err)
43+ }
44+ })
45+ }
46+}
47+
48+// Job and action results, as the runner's ActionResult enum names them.
49+const (
50+ resNone = ""
51+ resSuccess = "Success"
52+ resFailure = "Failure"
53+ resCancelled = "Cancelled"
54+)
55+
56+// statusFuncs mirrors src/Runner.Worker/Expressions/{Always,Cancelled,Failure,
57+// Success}Function.cs. A missing job status counts as Success. In a composite
58+// action's main step (isEmbedded and main stage) failure() and success() read
59+// the action status instead of the job status; cancelled() and always() never do.
60+func statusFuncs(jobStatus, actionStatus string, compositeMain bool) (map[string]Func, []FuncInfo) {
61+ orSuccess := func(s string) string {
62+ if s == resNone {
63+ return resSuccess
64+ }
65+ return s
66+ }
67+ status := func() string {
68+ if compositeMain {
69+ return orSuccess(actionStatus)
70+ }
71+ return orSuccess(jobStatus)
72+ }
73+ fn := func(f func() bool) Func {
74+ return func(context.Context, []Value) (Value, error) { return Boolean(f()), nil }
75+ }
76+ funcs := map[string]Func{
77+ "always": fn(func() bool { return true }),
78+ "cancelled": fn(func() bool { return orSuccess(jobStatus) == resCancelled }),
79+ "failure": fn(func() bool { return status() == resFailure }),
80+ "success": fn(func() bool { return status() == resSuccess }),
81+ }
82+ var infos []FuncInfo
83+ for _, n := range []string{"always", "cancelled", "failure", "success"} {
84+ infos = append(infos, FuncInfo{Name: n, MinArgs: 0, MaxArgs: 0})
85+ }
86+ return funcs, infos
87+}
88+
89+func evalCondition(t *testing.T, src, jobStatus, actionStatus string, compositeMain bool) bool {
90+ t.Helper()
91+ funcs, infos := statusFuncs(jobStatus, actionStatus, compositeMain)
92+ node, err := Parse(src, nil, infos)
93+ if err != nil {
94+ t.Fatalf("Parse(%q): %v", src, err)
95+ }
96+ v, err := Evaluate(context.Background(), node, EvalContext{Funcs: funcs})
97+ if err != nil {
98+ t.Fatalf("Evaluate(%q): %v", src, err)
99+ }
100+ return Truthy(v)
101+}
102+
103+// TestRunnerConditionFunctionsL0 ports AlwaysFunction, CancelledFunction,
104+// FailureFunction and SuccessFunction of ConditionFunctionsL0, and the two
105+// composite-action theories. The functions themselves live in the runner; what
106+// is checked here is that package expr carries zero-argument extension
107+// functions the way the runner declares them (FunctionInfo(name, 0, 0)).
108+func TestRunnerConditionFunctionsL0(t *testing.T) {
109+ jobStatuses := []string{resNone, resCancelled, resFailure, resSuccess}
110+ want := map[string]map[string]bool{
111+ "always": {resNone: true, resCancelled: true, resFailure: true, resSuccess: true},
112+ "cancelled": {resNone: false, resCancelled: true, resFailure: false, resSuccess: false},
113+ "failure": {resNone: false, resCancelled: false, resFailure: true, resSuccess: false},
114+ "success": {resNone: true, resCancelled: false, resFailure: false, resSuccess: true},
115+ }
116+ for name, byStatus := range want {
117+ for _, js := range jobStatuses {
118+ if got := evalCondition(t, name+"()", js, "", false); got != byStatus[js] {
119+ t.Errorf("%s() with job status %q = %v, want %v", name, js, got, byStatus[js])
120+ }
121+ // Names are case-insensitive, as in the runner's dictionaries.
122+ if got := evalCondition(t, strings.ToUpper(name)+"()", js, "", false); got != byStatus[js] {
123+ t.Errorf("%s() with job status %q = %v, want %v", strings.ToUpper(name), js, got, byStatus[js])
124+ }
125+ }
126+ }
127+
128+ composite := []struct {
129+ fn string
130+ job, action string
131+ want bool
132+ }{
133+ {"failure", resFailure, resFailure, true},
134+ {"failure", resFailure, resSuccess, false},
135+ {"failure", resSuccess, resFailure, true},
136+ {"failure", resSuccess, resSuccess, false},
137+ {"failure", resSuccess, resNone, false},
138+ {"success", resFailure, resFailure, false},
139+ {"success", resFailure, resSuccess, true},
140+ {"success", resSuccess, resFailure, false},
141+ {"success", resSuccess, resSuccess, true},
142+ {"success", resSuccess, resNone, true},
143+ }
144+ for _, tc := range composite {
145+ if got := evalCondition(t, tc.fn+"()", tc.job, tc.action, true); got != tc.want {
146+ t.Errorf("%s() in a composite main step, job %q action %q = %v, want %v", tc.fn, tc.job, tc.action, got, tc.want)
147+ }
148+ }
149+
150+ // Arity is declared 0..0: the parser rejects arguments.
151+ _, infos := statusFuncs("", "", false)
152+ if _, err := Parse("success(1)", nil, infos); err == nil || !strings.Contains(err.Error(), "Too many parameters supplied") {
153+ t.Errorf("success(1) = %v, want Too many parameters supplied", err)
154+ }
155+ // A condition combining them: runner conditions are ordinary expressions.
156+ if !evalCondition(t, "failure() || cancelled()", resCancelled, "", false) || evalCondition(t, "!always()", resFailure, "", false) {
157+ t.Error("combined status conditions")
158+ }
159+}

Keyboard shortcuts

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

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

Branches 1

Tags 3

↑↓ moveenter openesc close