72dd2d344c
Prevents random failing of all other runs when qemu binfmt is not set up Signed-off-by: Ryan <me@hackerc.at> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
572 lines
16 KiB
Go
572 lines
16 KiB
Go
package runner
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/nektos/act/pkg/model"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
assert "github.com/stretchr/testify/assert"
|
|
yaml "gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func TestRunContext_EvalBool(t *testing.T) {
|
|
var yml yaml.Node
|
|
err := yml.Encode(map[string][]interface{}{
|
|
"os": {"Linux", "Windows"},
|
|
"foo": {"bar", "baz"},
|
|
})
|
|
assert.NoError(t, err)
|
|
|
|
rc := &RunContext{
|
|
Config: &Config{
|
|
Workdir: ".",
|
|
},
|
|
Env: map[string]string{
|
|
"SOMETHING_TRUE": "true",
|
|
"SOMETHING_FALSE": "false",
|
|
"SOME_TEXT": "text",
|
|
},
|
|
Run: &model.Run{
|
|
JobID: "job1",
|
|
Workflow: &model.Workflow{
|
|
Name: "test-workflow",
|
|
Jobs: map[string]*model.Job{
|
|
"job1": {
|
|
Strategy: &model.Strategy{
|
|
RawMatrix: yml,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Matrix: map[string]interface{}{
|
|
"os": "Linux",
|
|
"foo": "bar",
|
|
},
|
|
StepResults: map[string]*model.StepResult{
|
|
"id1": {
|
|
Conclusion: model.StepStatusSuccess,
|
|
Outcome: model.StepStatusFailure,
|
|
Outputs: map[string]string{
|
|
"foo": "bar",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
rc.ExprEval = rc.NewExpressionEvaluator()
|
|
|
|
tables := []struct {
|
|
in string
|
|
out bool
|
|
wantErr bool
|
|
}{
|
|
// The basic ones
|
|
{in: "failure()", out: false},
|
|
{in: "success()", out: true},
|
|
{in: "cancelled()", out: false},
|
|
{in: "always()", out: true},
|
|
// TODO: move to sc.NewExpressionEvaluator(), because "steps" context is not available here
|
|
// {in: "steps.id1.conclusion == 'success'", out: true},
|
|
// {in: "steps.id1.conclusion != 'success'", out: false},
|
|
// {in: "steps.id1.outcome == 'failure'", out: true},
|
|
// {in: "steps.id1.outcome != 'failure'", out: false},
|
|
{in: "true", out: true},
|
|
{in: "false", out: false},
|
|
// TODO: This does not throw an error, because the evaluator does not know if the expression is inside ${{ }} or not
|
|
// {in: "!true", wantErr: true},
|
|
// {in: "!false", wantErr: true},
|
|
{in: "1 != 0", out: true},
|
|
{in: "1 != 1", out: false},
|
|
{in: "${{ 1 != 0 }}", out: true},
|
|
{in: "${{ 1 != 1 }}", out: false},
|
|
{in: "1 == 0", out: false},
|
|
{in: "1 == 1", out: true},
|
|
{in: "1 > 2", out: false},
|
|
{in: "1 < 2", out: true},
|
|
// And or
|
|
{in: "true && false", out: false},
|
|
{in: "true && 1 < 2", out: true},
|
|
{in: "false || 1 < 2", out: true},
|
|
{in: "false || false", out: false},
|
|
// None boolable
|
|
{in: "env.UNKNOWN == 'true'", out: false},
|
|
{in: "env.UNKNOWN", out: false},
|
|
// Inline expressions
|
|
{in: "env.SOME_TEXT", out: true},
|
|
{in: "env.SOME_TEXT == 'text'", out: true},
|
|
{in: "env.SOMETHING_TRUE == 'true'", out: true},
|
|
{in: "env.SOMETHING_FALSE == 'true'", out: false},
|
|
{in: "env.SOMETHING_TRUE", out: true},
|
|
{in: "env.SOMETHING_FALSE", out: true},
|
|
// TODO: This does not throw an error, because the evaluator does not know if the expression is inside ${{ }} or not
|
|
// {in: "!env.SOMETHING_TRUE", wantErr: true},
|
|
// {in: "!env.SOMETHING_FALSE", wantErr: true},
|
|
{in: "${{ !env.SOMETHING_TRUE }}", out: false},
|
|
{in: "${{ !env.SOMETHING_FALSE }}", out: false},
|
|
{in: "${{ ! env.SOMETHING_TRUE }}", out: false},
|
|
{in: "${{ ! env.SOMETHING_FALSE }}", out: false},
|
|
{in: "${{ env.SOMETHING_TRUE }}", out: true},
|
|
{in: "${{ env.SOMETHING_FALSE }}", out: true},
|
|
{in: "${{ !env.SOMETHING_TRUE }}", out: false},
|
|
{in: "${{ !env.SOMETHING_FALSE }}", out: false},
|
|
{in: "${{ !env.SOMETHING_TRUE && true }}", out: false},
|
|
{in: "${{ !env.SOMETHING_FALSE && true }}", out: false},
|
|
{in: "${{ !env.SOMETHING_TRUE || true }}", out: true},
|
|
{in: "${{ !env.SOMETHING_FALSE || false }}", out: false},
|
|
{in: "${{ env.SOMETHING_TRUE && true }}", out: true},
|
|
{in: "${{ env.SOMETHING_FALSE || true }}", out: true},
|
|
{in: "${{ env.SOMETHING_FALSE || false }}", out: true},
|
|
// TODO: This does not throw an error, because the evaluator does not know if the expression is inside ${{ }} or not
|
|
// {in: "!env.SOMETHING_TRUE || true", wantErr: true},
|
|
{in: "${{ env.SOMETHING_TRUE == 'true'}}", out: true},
|
|
{in: "${{ env.SOMETHING_FALSE == 'true'}}", out: false},
|
|
{in: "${{ env.SOMETHING_FALSE == 'false'}}", out: true},
|
|
{in: "${{ env.SOMETHING_FALSE }} && ${{ env.SOMETHING_TRUE }}", out: true},
|
|
|
|
// All together now
|
|
{in: "false || env.SOMETHING_TRUE == 'true'", out: true},
|
|
{in: "true || env.SOMETHING_FALSE == 'true'", out: true},
|
|
{in: "true && env.SOMETHING_TRUE == 'true'", out: true},
|
|
{in: "false && env.SOMETHING_TRUE == 'true'", out: false},
|
|
{in: "env.SOMETHING_FALSE == 'true' && env.SOMETHING_TRUE == 'true'", out: false},
|
|
{in: "env.SOMETHING_FALSE == 'true' && true", out: false},
|
|
{in: "${{ env.SOMETHING_FALSE == 'true' }} && true", out: true},
|
|
{in: "true && ${{ env.SOMETHING_FALSE == 'true' }}", out: true},
|
|
// Check github context
|
|
{in: "github.actor == 'nektos/act'", out: true},
|
|
{in: "github.actor == 'unknown'", out: false},
|
|
// The special ACT flag
|
|
{in: "${{ env.ACT }}", out: true},
|
|
{in: "${{ !env.ACT }}", out: false},
|
|
// Invalid expressions should be reported
|
|
{in: "INVALID_EXPRESSION", wantErr: true},
|
|
}
|
|
|
|
updateTestIfWorkflow(t, tables, rc)
|
|
for _, table := range tables {
|
|
table := table
|
|
t.Run(table.in, func(t *testing.T) {
|
|
assertObject := assert.New(t)
|
|
b, err := EvalBool(rc.ExprEval, table.in)
|
|
if table.wantErr {
|
|
assertObject.Error(err)
|
|
}
|
|
|
|
assertObject.Equal(table.out, b, fmt.Sprintf("Expected %s to be %v, was %v", table.in, table.out, b))
|
|
})
|
|
}
|
|
}
|
|
|
|
func updateTestIfWorkflow(t *testing.T, tables []struct {
|
|
in string
|
|
out bool
|
|
wantErr bool
|
|
}, rc *RunContext) {
|
|
var envs string
|
|
keys := make([]string, 0, len(rc.Env))
|
|
for k := range rc.Env {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
envs += fmt.Sprintf(" %s: %s\n", k, rc.Env[k])
|
|
}
|
|
// editorconfig-checker-disable
|
|
workflow := fmt.Sprintf(`
|
|
name: "Test what expressions result in true and false on GitHub"
|
|
on: push
|
|
|
|
env:
|
|
%s
|
|
|
|
jobs:
|
|
test-ifs-and-buts:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
`, envs)
|
|
// editorconfig-checker-enable
|
|
|
|
for i, table := range tables {
|
|
if table.wantErr || strings.HasPrefix(table.in, "github.actor") {
|
|
continue
|
|
}
|
|
expressionPattern := regexp.MustCompile(`\${{\s*(.+?)\s*}}`)
|
|
|
|
expr := expressionPattern.ReplaceAllStringFunc(table.in, func(match string) string {
|
|
return fmt.Sprintf("€{{ %s }}", expressionPattern.ReplaceAllString(match, "$1"))
|
|
})
|
|
echo := fmt.Sprintf(`run: echo "%s should be false, but was evaluated to true;" exit 1;`, table.in)
|
|
name := fmt.Sprintf(`"❌ I should not run, expr: %s"`, expr)
|
|
if table.out {
|
|
echo = `run: echo OK`
|
|
name = fmt.Sprintf(`"✅ I should run, expr: %s"`, expr)
|
|
}
|
|
workflow += fmt.Sprintf("\n - name: %s\n id: step%d\n if: %s\n %s\n", name, i, table.in, echo)
|
|
if table.out {
|
|
workflow += fmt.Sprintf("\n - name: \"Double checking expr: %s\"\n if: steps.step%d.conclusion == 'skipped'\n run: echo \"%s should have been true, but wasn't\"\n", expr, i, table.in)
|
|
}
|
|
}
|
|
|
|
file, err := os.Create("../../.github/workflows/test-if.yml")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = file.WriteString(workflow)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|
rctemplate := &RunContext{
|
|
Name: "TestRCName",
|
|
Run: &model.Run{
|
|
Workflow: &model.Workflow{
|
|
Name: "TestWorkflowName",
|
|
},
|
|
},
|
|
Config: &Config{
|
|
BindWorkdir: false,
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
windowsPath bool
|
|
name string
|
|
rc *RunContext
|
|
wantbind string
|
|
wantmount string
|
|
}{
|
|
{false, "/mnt/linux", rctemplate, "/mnt/linux", "/mnt/linux"},
|
|
{false, "/mnt/path with spaces/linux", rctemplate, "/mnt/path with spaces/linux", "/mnt/path with spaces/linux"},
|
|
{true, "C:\\Users\\TestPath\\MyTestPath", rctemplate, "/mnt/c/Users/TestPath/MyTestPath", "/mnt/c/Users/TestPath/MyTestPath"},
|
|
{true, "C:\\Users\\Test Path with Spaces\\MyTestPath", rctemplate, "/mnt/c/Users/Test Path with Spaces/MyTestPath", "/mnt/c/Users/Test Path with Spaces/MyTestPath"},
|
|
{true, "/LinuxPathOnWindowsShouldFail", rctemplate, "", ""},
|
|
}
|
|
|
|
isWindows := runtime.GOOS == "windows"
|
|
|
|
for _, testcase := range tests {
|
|
// pin for scopelint
|
|
testcase := testcase
|
|
for _, bindWorkDir := range []bool{true, false} {
|
|
// pin for scopelint
|
|
bindWorkDir := bindWorkDir
|
|
testBindSuffix := ""
|
|
if bindWorkDir {
|
|
testBindSuffix = "Bind"
|
|
}
|
|
|
|
// Only run windows path tests on windows and non-windows on non-windows
|
|
if (isWindows && testcase.windowsPath) || (!isWindows && !testcase.windowsPath) {
|
|
t.Run((testcase.name + testBindSuffix), func(t *testing.T) {
|
|
config := testcase.rc.Config
|
|
config.Workdir = testcase.name
|
|
config.BindWorkdir = bindWorkDir
|
|
gotbind, gotmount := rctemplate.GetBindsAndMounts()
|
|
|
|
// Name binds/mounts are either/or
|
|
if config.BindWorkdir {
|
|
fullBind := testcase.name + ":" + testcase.wantbind
|
|
if runtime.GOOS == "darwin" {
|
|
fullBind += ":delegated"
|
|
}
|
|
assert.Contains(t, gotbind, fullBind)
|
|
} else {
|
|
mountkey := testcase.rc.jobContainerName()
|
|
assert.EqualValues(t, testcase.wantmount, gotmount[mountkey])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Run("ContainerVolumeMountTest", func(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
volumes []string
|
|
wantbind string
|
|
wantmount map[string]string
|
|
}{
|
|
{"BindAnonymousVolume", []string{"/volume"}, "/volume", map[string]string{}},
|
|
{"BindHostFile", []string{"/path/to/file/on/host:/volume"}, "/path/to/file/on/host:/volume", map[string]string{}},
|
|
{"MountExistingVolume", []string{"volume-id:/volume"}, "", map[string]string{"volume-id": "/volume"}},
|
|
}
|
|
|
|
for _, testcase := range tests {
|
|
t.Run(testcase.name, func(t *testing.T) {
|
|
job := &model.Job{}
|
|
err := job.RawContainer.Encode(map[string][]string{
|
|
"volumes": testcase.volumes,
|
|
})
|
|
assert.NoError(t, err)
|
|
|
|
rc := rctemplate.Clone()
|
|
rc.Run.JobID = "job1"
|
|
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
|
|
|
gotbind, gotmount := rc.GetBindsAndMounts()
|
|
|
|
if len(testcase.wantbind) > 0 {
|
|
assert.Contains(t, gotbind, testcase.wantbind)
|
|
}
|
|
|
|
for k, v := range testcase.wantmount {
|
|
assert.Contains(t, gotmount, k)
|
|
assert.Equal(t, gotmount[k], v)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetGitHubContext(t *testing.T) {
|
|
log.SetLevel(log.DebugLevel)
|
|
|
|
cwd, err := os.Getwd()
|
|
assert.Nil(t, err)
|
|
|
|
rc := &RunContext{
|
|
Config: &Config{
|
|
EventName: "push",
|
|
Workdir: cwd,
|
|
},
|
|
Run: &model.Run{
|
|
Workflow: &model.Workflow{
|
|
Name: "GitHubContextTest",
|
|
},
|
|
},
|
|
Name: "GitHubContextTest",
|
|
CurrentStep: "step",
|
|
Matrix: map[string]interface{}{},
|
|
Env: map[string]string{},
|
|
ExtraPath: []string{},
|
|
StepResults: map[string]*model.StepResult{},
|
|
OutputMappings: map[MappableOutput]MappableOutput{},
|
|
}
|
|
|
|
ghc := rc.getGithubContext()
|
|
|
|
log.Debugf("%v", ghc)
|
|
|
|
actor := "nektos/act"
|
|
if a := os.Getenv("ACT_ACTOR"); a != "" {
|
|
actor = a
|
|
}
|
|
|
|
repo := "nektos/act"
|
|
if r := os.Getenv("ACT_REPOSITORY"); r != "" {
|
|
repo = r
|
|
}
|
|
|
|
owner := "nektos"
|
|
if o := os.Getenv("ACT_OWNER"); o != "" {
|
|
owner = o
|
|
}
|
|
|
|
assert.Equal(t, ghc.RunID, "1")
|
|
assert.Equal(t, ghc.Workspace, rc.Config.containerPath(cwd))
|
|
assert.Equal(t, ghc.RunNumber, "1")
|
|
assert.Equal(t, ghc.RetentionDays, "0")
|
|
assert.Equal(t, ghc.Actor, actor)
|
|
assert.Equal(t, ghc.Repository, repo)
|
|
assert.Equal(t, ghc.RepositoryOwner, owner)
|
|
assert.Equal(t, ghc.RunnerPerflog, "/dev/null")
|
|
assert.Equal(t, ghc.EventPath, ActPath+"/workflow/event.json")
|
|
assert.Equal(t, ghc.Token, rc.Config.Secrets["GITHUB_TOKEN"])
|
|
}
|
|
|
|
func createIfTestRunContext(jobs map[string]*model.Job) *RunContext {
|
|
rc := &RunContext{
|
|
Config: &Config{
|
|
Workdir: ".",
|
|
Platforms: map[string]string{
|
|
"ubuntu-latest": "ubuntu-latest",
|
|
},
|
|
},
|
|
Env: map[string]string{},
|
|
Run: &model.Run{
|
|
JobID: "job1",
|
|
Workflow: &model.Workflow{
|
|
Name: "test-workflow",
|
|
Jobs: jobs,
|
|
},
|
|
},
|
|
}
|
|
rc.ExprEval = rc.NewExpressionEvaluator()
|
|
|
|
return rc
|
|
}
|
|
|
|
func createJob(t *testing.T, input string, result string) *model.Job {
|
|
var job *model.Job
|
|
err := yaml.Unmarshal([]byte(input), &job)
|
|
assert.NoError(t, err)
|
|
job.Result = result
|
|
|
|
return job
|
|
}
|
|
|
|
func TestRunContextIsEnabled(t *testing.T) {
|
|
log.SetLevel(log.DebugLevel)
|
|
assertObject := assert.New(t)
|
|
|
|
// success()
|
|
rc := createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest
|
|
if: success()`, ""),
|
|
})
|
|
assertObject.True(rc.isEnabled(context.Background()))
|
|
|
|
rc = createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
|
"job2": createJob(t, `runs-on: ubuntu-latest
|
|
needs: [job1]
|
|
if: success()`, ""),
|
|
})
|
|
rc.Run.JobID = "job2"
|
|
assertObject.False(rc.isEnabled(context.Background()))
|
|
|
|
rc = createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "success"),
|
|
"job2": createJob(t, `runs-on: ubuntu-latest
|
|
needs: [job1]
|
|
if: success()`, ""),
|
|
})
|
|
rc.Run.JobID = "job2"
|
|
assertObject.True(rc.isEnabled(context.Background()))
|
|
|
|
rc = createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
|
"job2": createJob(t, `runs-on: ubuntu-latest
|
|
if: success()`, ""),
|
|
})
|
|
rc.Run.JobID = "job2"
|
|
assertObject.True(rc.isEnabled(context.Background()))
|
|
|
|
// failure()
|
|
rc = createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest
|
|
if: failure()`, ""),
|
|
})
|
|
assertObject.False(rc.isEnabled(context.Background()))
|
|
|
|
rc = createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
|
"job2": createJob(t, `runs-on: ubuntu-latest
|
|
needs: [job1]
|
|
if: failure()`, ""),
|
|
})
|
|
rc.Run.JobID = "job2"
|
|
assertObject.True(rc.isEnabled(context.Background()))
|
|
|
|
rc = createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "success"),
|
|
"job2": createJob(t, `runs-on: ubuntu-latest
|
|
needs: [job1]
|
|
if: failure()`, ""),
|
|
})
|
|
rc.Run.JobID = "job2"
|
|
assertObject.False(rc.isEnabled(context.Background()))
|
|
|
|
rc = createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
|
"job2": createJob(t, `runs-on: ubuntu-latest
|
|
if: failure()`, ""),
|
|
})
|
|
rc.Run.JobID = "job2"
|
|
assertObject.False(rc.isEnabled(context.Background()))
|
|
|
|
// always()
|
|
rc = createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest
|
|
if: always()`, ""),
|
|
})
|
|
assertObject.True(rc.isEnabled(context.Background()))
|
|
|
|
rc = createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
|
"job2": createJob(t, `runs-on: ubuntu-latest
|
|
needs: [job1]
|
|
if: always()`, ""),
|
|
})
|
|
rc.Run.JobID = "job2"
|
|
assertObject.True(rc.isEnabled(context.Background()))
|
|
|
|
rc = createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "success"),
|
|
"job2": createJob(t, `runs-on: ubuntu-latest
|
|
needs: [job1]
|
|
if: always()`, ""),
|
|
})
|
|
rc.Run.JobID = "job2"
|
|
assertObject.True(rc.isEnabled(context.Background()))
|
|
|
|
rc = createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "success"),
|
|
"job2": createJob(t, `runs-on: ubuntu-latest
|
|
if: always()`, ""),
|
|
})
|
|
rc.Run.JobID = "job2"
|
|
assertObject.True(rc.isEnabled(context.Background()))
|
|
}
|
|
|
|
func TestRunContextGetEnv(t *testing.T) {
|
|
tests := []struct {
|
|
description string
|
|
rc *RunContext
|
|
targetEnv string
|
|
want string
|
|
}{
|
|
{
|
|
description: "Env from Config should overwrite",
|
|
rc: &RunContext{
|
|
Config: &Config{
|
|
Env: map[string]string{"OVERWRITTEN": "true"},
|
|
},
|
|
Run: &model.Run{
|
|
Workflow: &model.Workflow{
|
|
Jobs: map[string]*model.Job{"test": {Name: "test"}},
|
|
Env: map[string]string{"OVERWRITTEN": "false"},
|
|
},
|
|
JobID: "test",
|
|
},
|
|
},
|
|
targetEnv: "OVERWRITTEN",
|
|
want: "true",
|
|
},
|
|
{
|
|
description: "No overwrite occurs",
|
|
rc: &RunContext{
|
|
Config: &Config{
|
|
Env: map[string]string{"SOME_OTHER_VAR": "true"},
|
|
},
|
|
Run: &model.Run{
|
|
Workflow: &model.Workflow{
|
|
Jobs: map[string]*model.Job{"test": {Name: "test"}},
|
|
Env: map[string]string{"OVERWRITTEN": "false"},
|
|
},
|
|
JobID: "test",
|
|
},
|
|
},
|
|
targetEnv: "OVERWRITTEN",
|
|
want: "false",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.description, func(t *testing.T) {
|
|
envMap := test.rc.GetEnv()
|
|
assert.EqualValues(t, test.want, envMap[test.targetEnv])
|
|
})
|
|
}
|
|
}
|